From b4cba018709562892ef8f8b0bca74a2fb7cdb868 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 10:17:01 +0200 Subject: [PATCH 01/42] Fix implicit-return in command_line (#122838) --- homeassistant/components/command_line/cover.py | 5 ++--- homeassistant/components/command_line/switch.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 6400be7d92fea..2c6ec78b689b9 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -4,7 +4,7 @@ import asyncio from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from homeassistant.components.cover import CoverEntity from homeassistant.const import ( @@ -145,8 +145,7 @@ async def _async_query_state(self) -> str | None: if self._command_state: LOGGER.info("Running state value command: %s", self._command_state) return await async_check_output_or_log(self._command_state, self._timeout) - if TYPE_CHECKING: - return None + return None async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8a75276c8b48c..f8e9d21cf23f3 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -4,7 +4,7 @@ import asyncio from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import Any, cast from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ( @@ -147,8 +147,7 @@ async def _async_query_state(self) -> str | int | None: if self._value_template: return await self._async_query_state_value(self._command_state) return await self._async_query_state_code(self._command_state) - if TYPE_CHECKING: - return None + return None async def _update_entity_state(self, now: datetime | None = None) -> None: """Update the state of the entity.""" From fa53055485ca6d338b3abc554163e55ac969f8cb Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 30 Jul 2024 11:57:56 +0300 Subject: [PATCH 02/42] Bump voluptuous-openapi (#122828) --- 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 f0c72a91501aa..b0e17bc2826ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -57,7 +57,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==0.10.1 urllib3>=1.26.5,<2 -voluptuous-openapi==0.0.4 +voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-noise-gain==1.2.3 diff --git a/pyproject.toml b/pyproject.toml index 70bfa1f18d8a1..eac18012ae334 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ "urllib3>=1.26.5,<2", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", - "voluptuous-openapi==0.0.4", + "voluptuous-openapi==0.0.5", "yarl==1.9.4", ] diff --git a/requirements.txt b/requirements.txt index 6e5ef50c187b4..5122cb99c41b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,5 +40,5 @@ ulid-transform==0.10.1 urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 -voluptuous-openapi==0.0.4 +voluptuous-openapi==0.0.5 yarl==1.9.4 From d825ac346ece81888d0231a51a028956e6917fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:00:08 +0200 Subject: [PATCH 03/42] Add 'use_custom_colors' to iOS Action configuration (#122767) --- homeassistant/components/ios/__init__.py | 2 ++ homeassistant/components/ios/const.py | 1 + 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 4b2b92a482de3..2a821166d8a91 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -29,6 +29,7 @@ CONF_ACTION_NAME, CONF_ACTION_SHOW_IN_CARPLAY, CONF_ACTION_SHOW_IN_WATCH, + CONF_ACTION_USE_CUSTOM_COLORS, CONF_ACTIONS, DOMAIN, ) @@ -152,6 +153,7 @@ }, vol.Optional(CONF_ACTION_SHOW_IN_CARPLAY): cv.boolean, vol.Optional(CONF_ACTION_SHOW_IN_WATCH): cv.boolean, + vol.Optional(CONF_ACTION_USE_CUSTOM_COLORS): cv.boolean, }, ) diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py index 41da1954b4493..181bbebd9a6ca 100644 --- a/homeassistant/components/ios/const.py +++ b/homeassistant/components/ios/const.py @@ -13,3 +13,4 @@ CONF_ACTIONS = "actions" CONF_ACTION_SHOW_IN_CARPLAY = "show_in_carplay" CONF_ACTION_SHOW_IN_WATCH = "show_in_watch" +CONF_ACTION_USE_CUSTOM_COLORS = "use_custom_colors" From d78acd480a44dfd03eb88fce92c5c0d3eba6d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Cl=C3=A9ment?= Date: Tue, 30 Jul 2024 11:23:55 +0200 Subject: [PATCH 04/42] Add QBittorent switch to control alternative speed (#107637) * Fix key in strings.json for current_status in QBittorrent * Add switch on QBittorent to control alternative speed * Add switch file to .coveragerc * Fix some typo * Use coordinator for switch * Update to mach new lib * Import annotation Co-authored-by: Erik Montnemery * Remove quoted coordinator * Revert "Fix key in strings.json for current_status in QBittorrent" This reverts commit 962fd0474f0c9d6053bcf34898f68e48cf2bb715. --------- Co-authored-by: Erik Montnemery --- .../components/qbittorrent/__init__.py | 2 +- .../components/qbittorrent/coordinator.py | 22 +++- .../components/qbittorrent/helpers.py | 1 - .../components/qbittorrent/strings.json | 5 + .../components/qbittorrent/switch.py | 104 ++++++++++++++++++ 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/qbittorrent/switch.py diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index fb781dd1a0cb5..d95136965f8c4 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] CONF_ENTRY = "entry" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 0ef36d2a9546e..c590bb9d81ae6 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -30,6 +30,7 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" self.client = client + self._is_alternative_mode_enabled = False # self.main_data: dict[str, int] = {} self.total_torrents: dict[str, int] = {} self.active_torrents: dict[str, int] = {} @@ -47,7 +48,13 @@ def __init__(self, hass: HomeAssistant, client: Client) -> None: async def _async_update_data(self) -> SyncMainDataDictionary: try: - return await self.hass.async_add_executor_job(self.client.sync_maindata) + data = await self.hass.async_add_executor_job(self.client.sync_maindata) + self._is_alternative_mode_enabled = ( + await self.hass.async_add_executor_job( + self.client.transfer_speed_limits_mode + ) + == "1" + ) except (LoginFailed, Forbidden403Error) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="login_error" @@ -56,6 +63,19 @@ async def _async_update_data(self) -> SyncMainDataDictionary: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="cannot_connect" ) from exc + return data + + def set_alt_speed_enabled(self, is_enabled: bool) -> None: + """Set the alternative speed mode.""" + self.client.transfer_toggle_speed_limits_mode(is_enabled) + + def toggle_alt_speed_enabled(self) -> None: + """Toggle the alternative speed mode.""" + self.client.transfer_toggle_speed_limits_mode() + + def get_alt_speed_enabled(self) -> bool: + """Get the alternative speed mode.""" + return self._is_alternative_mode_enabled async def get_torrents(self, torrent_filter: TorrentStatusesT) -> TorrentInfoList: """Async method to get QBittorrent torrents.""" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py index fac0a6033fa53..6b459e9974100 100644 --- a/homeassistant/components/qbittorrent/helpers.py +++ b/homeassistant/components/qbittorrent/helpers.py @@ -8,7 +8,6 @@ def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: """Create a qBittorrent client.""" - client = Client( url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 948e9dca8e9bc..fe27beb2a2d23 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -47,6 +47,11 @@ "all_torrents": { "name": "All torrents" } + }, + "switch": { + "alternative_speed": { + "name": "Alternative speed" + } } }, "services": { diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py new file mode 100644 index 0000000000000..f12118e523316 --- /dev/null +++ b/homeassistant/components/qbittorrent/switch.py @@ -0,0 +1,104 @@ +"""Support for monitoring the qBittorrent API.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +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.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator + + +@dataclass(frozen=True, kw_only=True) +class QBittorrentSwitchEntityDescription(SwitchEntityDescription): + """Describes qBittorren switch.""" + + is_on_func: Callable[[QBittorrentDataCoordinator], bool] + turn_on_fn: Callable[[QBittorrentDataCoordinator], None] + turn_off_fn: Callable[[QBittorrentDataCoordinator], None] + toggle_func: Callable[[QBittorrentDataCoordinator], None] + + +SWITCH_TYPES: tuple[QBittorrentSwitchEntityDescription, ...] = ( + QBittorrentSwitchEntityDescription( + key="alternative_speed", + translation_key="alternative_speed", + icon="mdi:speedometer-slow", + is_on_func=lambda coordinator: coordinator.get_alt_speed_enabled(), + turn_on_fn=lambda coordinator: coordinator.set_alt_speed_enabled(True), + turn_off_fn=lambda coordinator: coordinator.set_alt_speed_enabled(False), + toggle_func=lambda coordinator: coordinator.toggle_alt_speed_enabled(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up qBittorrent switch entries.""" + + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + QBittorrentSwitch(coordinator, config_entry, description) + for description in SWITCH_TYPES + ) + + +class QBittorrentSwitch(CoordinatorEntity[QBittorrentDataCoordinator], SwitchEntity): + """Representation of a qBittorrent switch.""" + + _attr_has_entity_name = True + entity_description: QBittorrentSwitchEntityDescription + + def __init__( + self, + coordinator: QBittorrentDataCoordinator, + config_entry: ConfigEntry, + entity_description: QBittorrentSwitchEntityDescription, + ) -> None: + """Initialize qBittorrent switch.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="QBittorrent", + ) + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self.entity_description.is_on_func(self.coordinator) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on this switch.""" + await self.hass.async_add_executor_job( + self.entity_description.turn_on_fn, self.coordinator + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off this switch.""" + await self.hass.async_add_executor_job( + self.entity_description.turn_off_fn, self.coordinator + ) + await self.coordinator.async_request_refresh() + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the device.""" + await self.hass.async_add_executor_job( + self.entity_description.toggle_func, self.coordinator + ) + await self.coordinator.async_request_refresh() From 53a59412bb12ff48dae11dd219bec593929cd4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Tue, 30 Jul 2024 02:34:30 -0700 Subject: [PATCH 05/42] Add Foscam sleep switch (#109491) * Add sleep switch * Replace awake with sleep switch --- homeassistant/components/foscam/__init__.py | 2 +- .../components/foscam/coordinator.py | 5 ++ homeassistant/components/foscam/strings.json | 7 ++ homeassistant/components/foscam/switch.py | 85 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/foscam/switch.py diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index aed3ed637ae0c..f8708a589ce06 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -17,7 +17,7 @@ from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET from .coordinator import FoscamCoordinator -PLATFORMS = [Platform.CAMERA] +PLATFORMS = [Platform.CAMERA, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 063d5235c04c5..e7a8abf7d3079 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -44,4 +44,9 @@ async def _async_update_data(self) -> dict[str, Any]: self.session.get_product_all_info ) data["product_info"] = all_info[1] + + ret, is_asleep = await self.hass.async_add_executor_job( + self.session.is_asleep + ) + data["is_asleep"] = {"supported": ret == 0, "status": is_asleep} return data diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 285f0f5a78070..2784e541809c6 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -25,6 +25,13 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "switch": { + "sleep_switch": { + "name": "Sleep" + } + } + }, "services": { "ptz": { "name": "PTZ", diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py new file mode 100644 index 0000000000000..9eae211881f3f --- /dev/null +++ b/homeassistant/components/foscam/switch.py @@ -0,0 +1,85 @@ +"""Component provides support for the Foscam Switch.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FoscamCoordinator +from .const import DOMAIN, LOGGER +from .entity import FoscamEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up foscam switch from a config entry.""" + + coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + await coordinator.async_config_entry_first_refresh() + + if coordinator.data["is_asleep"]["supported"]: + async_add_entities([FoscamSleepSwitch(coordinator, config_entry)]) + + +class FoscamSleepSwitch(FoscamEntity, SwitchEntity): + """An implementation for Sleep Switch.""" + + def __init__( + self, + coordinator: FoscamCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Initialize a Foscam Sleep Switch.""" + super().__init__(coordinator, config_entry.entry_id) + + self._attr_unique_id = "sleep_switch" + self._attr_translation_key = "sleep_switch" + self._attr_has_entity_name = True + + self.is_asleep = self.coordinator.data["is_asleep"]["status"] + + @property + def is_on(self): + """Return true if camera is asleep.""" + return self.is_asleep + + async def async_turn_off(self, **kwargs: Any) -> None: + """Wake camera.""" + LOGGER.debug("Wake camera") + + ret, _ = await self.hass.async_add_executor_job( + self.coordinator.session.wake_up + ) + + if ret != 0: + raise HomeAssistantError(f"Error waking up: {ret}") + + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """But camera is sleep.""" + LOGGER.debug("Sleep camera") + + ret, _ = await self.hass.async_add_executor_job(self.coordinator.session.sleep) + + if ret != 0: + raise HomeAssistantError(f"Error sleeping: {ret}") + + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + self.is_asleep = self.coordinator.data["is_asleep"]["status"] + + self.async_write_ha_state() From 7c92287f972554826f4dd0bb99652ad88c7c64b6 Mon Sep 17 00:00:00 2001 From: Luke Wale Date: Tue, 30 Jul 2024 18:34:49 +0800 Subject: [PATCH 06/42] Add Airtouch5 cover tests (#122769) add airtouch5 cover tests --- tests/components/airtouch5/__init__.py | 12 ++ tests/components/airtouch5/conftest.py | 118 +++++++++++++++ .../airtouch5/snapshots/test_cover.ambr | 99 ++++++++++++ tests/components/airtouch5/test_cover.py | 143 ++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 tests/components/airtouch5/snapshots/test_cover.ambr create mode 100644 tests/components/airtouch5/test_cover.py diff --git a/tests/components/airtouch5/__init__.py b/tests/components/airtouch5/__init__.py index 2b76786e7e5bf..567be6af774d1 100644 --- a/tests/components/airtouch5/__init__.py +++ b/tests/components/airtouch5/__init__.py @@ -1 +1,13 @@ """Tests for the Airtouch 5 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/airtouch5/conftest.py b/tests/components/airtouch5/conftest.py index ca678258c7717..fab26e3f6cc67 100644 --- a/tests/components/airtouch5/conftest.py +++ b/tests/components/airtouch5/conftest.py @@ -3,8 +3,22 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from airtouch5py.data_packet_factory import DataPacketFactory +from airtouch5py.packets.ac_ability import AcAbility +from airtouch5py.packets.ac_status import AcFanSpeed, AcMode, AcPowerState, AcStatus +from airtouch5py.packets.zone_name import ZoneName +from airtouch5py.packets.zone_status import ( + ControlMethod, + ZonePowerState, + ZoneStatusZone, +) import pytest +from homeassistant.components.airtouch5.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +27,107 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.airtouch5.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( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + }, + ) + + +@pytest.fixture +def mock_airtouch5_client() -> Generator[AsyncMock]: + """Mock an Airtouch5 client.""" + + with ( + patch( + "homeassistant.components.airtouch5.Airtouch5SimpleClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.airtouch5.config_flow.Airtouch5SimpleClient", + new=mock_client, + ), + ): + client = mock_client.return_value + + # Default values for the tests using this mock : + client.data_packet_factory = DataPacketFactory() + client.ac = [ + AcAbility( + ac_number=1, + ac_name="AC 1", + start_zone_number=1, + zone_count=2, + supports_mode_cool=True, + supports_mode_fan=True, + supports_mode_dry=True, + supports_mode_heat=True, + supports_mode_auto=True, + supports_fan_speed_intelligent_auto=True, + supports_fan_speed_turbo=True, + supports_fan_speed_powerful=True, + supports_fan_speed_high=True, + supports_fan_speed_medium=True, + supports_fan_speed_low=True, + supports_fan_speed_quiet=True, + supports_fan_speed_auto=True, + min_cool_set_point=15, + max_cool_set_point=25, + min_heat_set_point=20, + max_heat_set_point=30, + ) + ] + client.latest_ac_status = { + 1: AcStatus( + ac_power_state=AcPowerState.ON, + ac_number=1, + ac_mode=AcMode.AUTO, + ac_fan_speed=AcFanSpeed.AUTO, + ac_setpoint=24, + turbo_active=False, + bypass_active=False, + spill_active=False, + timer_set=False, + temperature=24, + error_code=0, + ) + } + + client.zones = [ZoneName(1, "Zone 1"), ZoneName(2, "Zone 2")] + client.latest_zone_status = { + 1: ZoneStatusZone( + zone_power_state=ZonePowerState.ON, + zone_number=1, + control_method=ControlMethod.PERCENTAGE_CONTROL, + open_percentage=0.9, + set_point=24, + has_sensor=False, + temperature=24, + spill_active=False, + is_low_battery=False, + ), + 2: ZoneStatusZone( + zone_power_state=ZonePowerState.ON, + zone_number=1, + control_method=ControlMethod.TEMPERATURE_CONTROL, + open_percentage=1, + set_point=24, + has_sensor=True, + temperature=24, + spill_active=False, + is_low_battery=False, + ), + } + + client.connection_state_callbacks = [] + client.zone_status_callbacks = [] + client.ac_status_callbacks = [] + + yield client diff --git a/tests/components/airtouch5/snapshots/test_cover.ambr b/tests/components/airtouch5/snapshots/test_cover.ambr new file mode 100644 index 0000000000000..a8e57f6952721 --- /dev/null +++ b/tests/components/airtouch5/snapshots/test_cover.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[cover.zone_1_damper-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.zone_1_damper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Damper', + 'platform': 'airtouch5', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'damper', + 'unique_id': 'zone_1_open_percentage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.zone_1_damper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 90, + 'device_class': 'damper', + 'friendly_name': 'Zone 1 Damper', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.zone_1_damper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_all_entities[cover.zone_2_damper-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.zone_2_damper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Damper', + 'platform': 'airtouch5', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'damper', + 'unique_id': 'zone_2_open_percentage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[cover.zone_2_damper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 100, + 'device_class': 'damper', + 'friendly_name': 'Zone 2 Damper', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.zone_2_damper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/airtouch5/test_cover.py b/tests/components/airtouch5/test_cover.py new file mode 100644 index 0000000000000..295535cd95d8d --- /dev/null +++ b/tests/components/airtouch5/test_cover.py @@ -0,0 +1,143 @@ +"""Tests for the Airtouch5 cover platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +from airtouch5py.packets.zone_status import ( + ControlMethod, + ZonePowerState, + ZoneStatusZone, +) +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + STATE_OPEN, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_CLOSED, 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 + +COVER_ENTITY_ID = "cover.zone_1_damper" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airtouch5_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.airtouch5.PLATFORMS", [Platform.COVER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_cover_actions( + hass: HomeAssistant, + mock_airtouch5_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the actions of the Airtouch5 covers.""" + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + mock_airtouch5_client.send_packet.assert_called_once() + mock_airtouch5_client.reset_mock() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_ENTITY_ID}, + blocking=True, + ) + mock_airtouch5_client.send_packet.assert_called_once() + mock_airtouch5_client.reset_mock() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: COVER_ENTITY_ID, ATTR_POSITION: 50}, + blocking=True, + ) + mock_airtouch5_client.send_packet.assert_called_once() + mock_airtouch5_client.reset_mock() + + +async def test_cover_callbacks( + hass: HomeAssistant, + mock_airtouch5_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the callbacks of the Airtouch5 covers.""" + + await setup_integration(hass, mock_config_entry) + + # We find the callback method on the mock client + zone_status_callback: Callable[[dict[int, ZoneStatusZone]], None] = ( + mock_airtouch5_client.zone_status_callbacks[2] + ) + + # Define a method to simply call it + async def _call_zone_status_callback(open_percentage: int) -> None: + zsz = ZoneStatusZone( + zone_power_state=ZonePowerState.ON, + zone_number=1, + control_method=ControlMethod.PERCENTAGE_CONTROL, + open_percentage=open_percentage, + set_point=None, + has_sensor=False, + temperature=None, + spill_active=False, + is_low_battery=False, + ) + zone_status_callback({1: zsz}) + await hass.async_block_till_done() + + # And call it to effectively launch the callback as the server would do + + # Partly open + await _call_zone_status_callback(0.7) + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 70 + + # Fully open + await _call_zone_status_callback(1) + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 100 + + # Fully closed + await _call_zone_status_callback(0.0) + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.state == STATE_CLOSED + assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 + + # Partly reopened + await _call_zone_status_callback(0.3) + state = hass.states.get(COVER_ENTITY_ID) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get(ATTR_CURRENT_POSITION) == 30 From b6f0893c336a575b9658b530eb89972b05abb8ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:05:38 +0200 Subject: [PATCH 07/42] Fix implicit-return in denon (#122835) --- homeassistant/components/denon/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index b3b3ba97baa36..0a6fe18d9869f 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -253,11 +253,12 @@ def supported_features(self) -> MediaPlayerEntityFeature: return SUPPORT_DENON @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" for pretty_name, name in self._source_list.items(): if self._mediasource == name: return pretty_name + return None def turn_off(self) -> None: """Turn off media player.""" From 015c50bbdb13788828b2e15f234dae4a5fbddf69 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:44:11 +0200 Subject: [PATCH 08/42] Fix implicit-return in ddwrt (#122837) --- homeassistant/components/ddwrt/device_tracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index 30ab3af53fbd6..5d31d16a530ea 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -162,6 +162,7 @@ def get_ddwrt_data(self, url): ) return None _LOGGER.error("Invalid response from DD-WRT: %s", response) + return None def _parse_ddwrt_response(data_str): From 956cc6a85cd13be520c2410989acde35a5538b88 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 30 Jul 2024 13:54:44 +0200 Subject: [PATCH 09/42] Add UI to create KNX switch and light entities (#122630) Update KNX frontend to 2024.7.25.204106 --- 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 3e8986641e77d..5035239d1fb14 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==2.12.2", "xknxproject==3.7.1", - "knx-frontend==2024.1.20.105944" + "knx-frontend==2024.7.25.204106" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 70565d41175cd..7a63bcb560209 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1213,7 +1213,7 @@ kiwiki-client==0.1.1 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.1.20.105944 +knx-frontend==2024.7.25.204106 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe01d45de76ac..9e8664f482179 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1009,7 +1009,7 @@ kegtron-ble==0.4.0 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.1.20.105944 +knx-frontend==2024.7.25.204106 # homeassistant.components.konnected konnected==1.2.0 From 72f9d85bbebb9c45b157974391728b12c6efb3ad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:57:43 +0200 Subject: [PATCH 10/42] Fix implicit-return in whirlpool tests (#122775) --- tests/components/whirlpool/conftest.py | 2 ++ tests/components/whirlpool/test_sensor.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index a5926f55a945f..50620b20b8b0d 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -145,6 +145,8 @@ def side_effect_function(*args, **kwargs): if args[0] == "WashCavity_OpStatusBulkDispense1Level": return "3" + return None + def get_sensor_mock(said): """Get a mock of a sensor.""" diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 6af88c8a9f3af..548025e29bde4 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -42,6 +42,8 @@ def side_effect_function_open_door(*args, **kwargs): if args[0] == "WashCavity_OpStatusBulkDispense1Level": return "3" + return None + async def test_dryer_sensor_values( hass: HomeAssistant, From e7971f5a679b245e0aec0fdcb8afc8ff5d7d5f60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Cl=C3=A9ment?= Date: Tue, 30 Jul 2024 15:03:36 +0200 Subject: [PATCH 11/42] Fix qbittorent current_status key in strings.json (#122848) --- homeassistant/components/qbittorrent/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index fe27beb2a2d23..88015dad5c38f 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -26,7 +26,7 @@ "upload_speed": { "name": "Upload speed" }, - "transmission_status": { + "current_status": { "name": "Status", "state": { "idle": "[%key:common::state::idle%]", From fd7c92879cccfa456088fa03f3063a36a707f791 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:23:04 +0200 Subject: [PATCH 12/42] Fix implicit-return in foursquare (#122843) --- homeassistant/components/foursquare/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index c0eac33a6a877..12a29fd632ef7 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -3,6 +3,7 @@ from http import HTTPStatus import logging +from aiohttp import web import requests import voluptuous as vol @@ -85,11 +86,11 @@ class FoursquarePushReceiver(HomeAssistantView): url = "/api/foursquare" name = "foursquare" - def __init__(self, push_secret): + def __init__(self, push_secret: str) -> None: """Initialize the OAuth callback view.""" self.push_secret = push_secret - async def post(self, request): + async def post(self, request: web.Request) -> web.Response | None: """Accept the POST from Foursquare.""" try: data = await request.json() @@ -107,3 +108,4 @@ async def post(self, request): return self.json_message("Incorrect secret", HTTPStatus.BAD_REQUEST) request.app[KEY_HASS].bus.async_fire(EVENT_PUSH, data) + return None From 41c7414d9784d5401bce706d109f83e4258b6323 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:23:53 +0200 Subject: [PATCH 13/42] Fix implicit-return in forked_daapd (#122842) --- homeassistant/components/forked_daapd/media_player.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 98ad2f28cafb6..b8b544c1a2ca7 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -827,12 +827,13 @@ def _use_pipe_control(self): return self._source[:-7] return "" - async def _pipe_call(self, pipe_name, base_function_name): - if self._pipe_control_api.get(pipe_name): - return await getattr( - self._pipe_control_api[pipe_name], + async def _pipe_call(self, pipe_name, base_function_name) -> None: + if pipe := self._pipe_control_api.get(pipe_name): + await getattr( + pipe, PIPE_FUNCTION_MAP[pipe_name][base_function_name], )() + return _LOGGER.warning("No pipe control available for %s", pipe_name) async def async_browse_media( From 27eba3cd4631a4efe3580e231532e0235bb4c367 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:24:35 +0200 Subject: [PATCH 14/42] Fix implicit-return in fixer (#122841) --- homeassistant/components/fixer/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 4a03de5d6de7b..f8b4546d4c7e4 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any from fixerio import Fixerio from fixerio.exceptions import FixerioException @@ -89,13 +90,14 @@ def native_value(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.data.rate is not None: return { ATTR_EXCHANGE_RATE: self.data.rate["rates"][self._target], ATTR_TARGET: self._target, } + return None def update(self) -> None: """Get the latest data and updates the states.""" From 7b5db6521c8a047570c968e95010d7279d486080 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:29:23 +0200 Subject: [PATCH 15/42] Fix implicit-return in advantage_air (#122840) --- homeassistant/components/advantage_air/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 7f9d3f2dc6537..8da46cc746362 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -206,7 +206,8 @@ async def async_turn_off(self) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - return await self.async_turn_off() + await self.async_turn_off() + return if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO: raise ServiceValidationError("Heat/Cool is not supported in this mode") await self.async_update_ac( From 09cd79772f8e1a07397ad6ea4dda48b1a47ccdb8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:29:53 +0200 Subject: [PATCH 16/42] Fix implicit-return in airtouch4 (#122839) --- homeassistant/components/airtouch4/climate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 29fd2bc4bed66..dbb6f02859b15 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -156,7 +156,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") if hvac_mode == HVACMode.OFF: - return await self.async_turn_off() + await self.async_turn_off() + return await self._airtouch.SetCoolingModeForAc( self._ac_number, HA_STATE_TO_AT[hvac_mode] ) @@ -262,7 +263,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") if hvac_mode == HVACMode.OFF: - return await self.async_turn_off() + await self.async_turn_off() + return if self.hvac_mode == HVACMode.OFF: await self.async_turn_on() self._unit = self._airtouch.GetGroups()[self._group_number] From ea508b26298aed78676c93d28b520e4e1f6a4356 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:32:29 +0200 Subject: [PATCH 17/42] Fix implicit-return in dialogflow (#122834) --- homeassistant/components/dialogflow/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 1c0da6b26eb15..da6fbaf9969ed 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -103,6 +103,8 @@ def get_api_version(message): if message.get("responseId") is not None: return V2 + raise ValueError(f"Unable to extract API version from message: {message}") + async def async_handle_message(hass, message): """Handle a DialogFlow message.""" @@ -173,3 +175,5 @@ def as_dict(self): if self.api_version is V2: return {"fulfillmentText": self.speech, "source": SOURCE} + + raise ValueError(f"Invalid API version: {self.api_version}") From 2135691b90f4e7069c6981f373d21293d6420273 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:33:05 +0200 Subject: [PATCH 18/42] Fix implicit-return in dublin bus transport (#122833) --- homeassistant/components/dublin_bus_transport/sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 91773d081424f..5fc3453fca631 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -9,6 +9,7 @@ from contextlib import suppress from datetime import datetime, timedelta from http import HTTPStatus +from typing import Any import requests import voluptuous as vol @@ -102,7 +103,7 @@ def native_value(self): return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self._times is not None: next_up = "None" @@ -117,6 +118,7 @@ def extra_state_attributes(self): ATTR_ROUTE: self._times[0][ATTR_ROUTE], ATTR_NEXT_UP: next_up, } + return None @property def native_unit_of_measurement(self): From c8372a3aa5094a1a96c3a64bed704f93763e43f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:33:57 +0200 Subject: [PATCH 19/42] Fix implicit-return in ecobee (#122832) --- homeassistant/components/ecobee/binary_sensor.py | 3 ++- homeassistant/components/ecobee/sensor.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 4286f2cf7578c..2a021442a631d 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -46,7 +46,7 @@ def __init__(self, data, sensor_name, sensor_index): self.index = sensor_index @property - def unique_id(self): + def unique_id(self) -> str | None: """Return a unique identifier for this sensor.""" for sensor in self.data.ecobee.get_remote_sensors(self.index): if sensor["name"] == self.sensor_name: @@ -54,6 +54,7 @@ def unique_id(self): return f"{sensor['code']}-{self.device_class}" thermostat = self.data.ecobee.get_thermostat(self.index) return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + return None @property def device_info(self) -> DeviceInfo | None: diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 3e2e984cccb7e..fe0442fb885cc 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -112,7 +112,7 @@ def __init__( self._state = None @property - def unique_id(self): + def unique_id(self) -> str | None: """Return a unique identifier for this sensor.""" for sensor in self.data.ecobee.get_remote_sensors(self.index): if sensor["name"] == self.sensor_name: @@ -120,6 +120,7 @@ def unique_id(self): return f"{sensor['code']}-{self.device_class}" thermostat = self.data.ecobee.get_thermostat(self.index) return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + return None @property def device_info(self) -> DeviceInfo | None: From 224228e4480b4f71c5722ed6ac33221c5b1b4710 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 16:16:33 +0200 Subject: [PATCH 20/42] Fix Axis tests affecting other tests (#122857) --- tests/components/axis/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 30e1b7335b90b..c3377c15955f2 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -128,6 +128,13 @@ def fixture_config_entry_options() -> MappingProxyType[str, Any]: # Axis API fixtures +@pytest.fixture(autouse=True) +def reset_mock_requests() -> Generator[None]: + """Reset respx mock routes after the test.""" + yield + respx.mock.clear() + + @pytest.fixture(name="mock_requests") def fixture_request( respx_mock: respx.MockRouter, From d9e996def5ad3824504e5062640b5393b2ec0132 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 16:18:47 +0200 Subject: [PATCH 21/42] Fix template binary sensor test (#122855) --- .../components/template/test_binary_sensor.py | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 50cad5be9e1b7..eb51b3f53b4db 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,5 +1,6 @@ """The tests for the Template Binary sensor platform.""" +from copy import deepcopy from datetime import UTC, datetime, timedelta import logging from unittest.mock import patch @@ -995,20 +996,32 @@ async def test_availability_icon_picture( ], ) @pytest.mark.parametrize( - ("extra_config", "restored_state", "initial_state"), + ("extra_config", "source_state", "restored_state", "initial_state"), [ - ({}, ON, OFF), - ({}, OFF, OFF), - ({}, STATE_UNAVAILABLE, OFF), - ({}, STATE_UNKNOWN, OFF), - ({"delay_off": 5}, ON, ON), - ({"delay_off": 5}, OFF, OFF), - ({"delay_off": 5}, STATE_UNAVAILABLE, STATE_UNKNOWN), - ({"delay_off": 5}, STATE_UNKNOWN, STATE_UNKNOWN), - ({"delay_on": 5}, ON, ON), - ({"delay_on": 5}, OFF, OFF), - ({"delay_on": 5}, STATE_UNAVAILABLE, STATE_UNKNOWN), - ({"delay_on": 5}, STATE_UNKNOWN, STATE_UNKNOWN), + ({}, OFF, ON, OFF), + ({}, OFF, OFF, OFF), + ({}, OFF, STATE_UNAVAILABLE, OFF), + ({}, OFF, STATE_UNKNOWN, OFF), + ({"delay_off": 5}, OFF, ON, ON), + ({"delay_off": 5}, OFF, OFF, OFF), + ({"delay_off": 5}, OFF, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_off": 5}, OFF, STATE_UNKNOWN, STATE_UNKNOWN), + ({"delay_on": 5}, OFF, ON, OFF), + ({"delay_on": 5}, OFF, OFF, OFF), + ({"delay_on": 5}, OFF, STATE_UNAVAILABLE, OFF), + ({"delay_on": 5}, OFF, STATE_UNKNOWN, OFF), + ({}, ON, ON, ON), + ({}, ON, OFF, ON), + ({}, ON, STATE_UNAVAILABLE, ON), + ({}, ON, STATE_UNKNOWN, ON), + ({"delay_off": 5}, ON, ON, ON), + ({"delay_off": 5}, ON, OFF, ON), + ({"delay_off": 5}, ON, STATE_UNAVAILABLE, ON), + ({"delay_off": 5}, ON, STATE_UNKNOWN, ON), + ({"delay_on": 5}, ON, ON, ON), + ({"delay_on": 5}, ON, OFF, OFF), + ({"delay_on": 5}, ON, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_on": 5}, ON, STATE_UNKNOWN, STATE_UNKNOWN), ], ) async def test_restore_state( @@ -1017,18 +1030,20 @@ async def test_restore_state( domain, config, extra_config, + source_state, restored_state, initial_state, ) -> None: """Test restoring template binary sensor.""" + hass.states.async_set("sensor.test_state", source_state) fake_state = State( "binary_sensor.test", restored_state, {}, ) mock_restore_cache(hass, (fake_state,)) - config = dict(config) + config = deepcopy(config) config["template"]["binary_sensor"].update(**extra_config) with assert_setup_component(count, domain): assert await async_setup_component( From a5136a10217f3e643952548402338312e5c3dd95 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 30 Jul 2024 16:27:58 +0200 Subject: [PATCH 22/42] Speed up slow tests in Husqvarna Automower (#122854) --- .../husqvarna_automower/test_number.py | 20 ++++++++++++++++--- .../husqvarna_automower/test_switch.py | 12 +++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index ac7353386ac0c..9f2f8793bba93 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -1,13 +1,18 @@ """Tests for number platform.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ApiException from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.components.husqvarna_automower.const import ( + DOMAIN, + EXECUTION_TIME_DELAY, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,7 +21,12 @@ from . import setup_integration from .const import TEST_MOWER_ID -from tests.common import MockConfigEntry, load_json_value_fixture, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, + snapshot_platform, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -57,6 +67,7 @@ async def test_number_workarea_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test number commands.""" entity_id = "number.test_mower_1_front_lawn_cutting_height" @@ -75,8 +86,11 @@ async def test_number_workarea_commands( service="set_value", target={"entity_id": entity_id}, service_data={"value": "75"}, - blocking=True, + 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, 75, 123456) state = hass.states.get(entity_id) assert state.state is not None diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 24fd63be749cc..5b4e465e2537e 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -1,5 +1,6 @@ """Tests for switch platform.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ApiException @@ -9,7 +10,10 @@ import pytest from syrupy import SnapshotAssertion -from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.components.husqvarna_automower.const import ( + DOMAIN, + EXECUTION_TIME_DELAY, +) from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -109,6 +113,7 @@ async def test_stay_out_zone_switch_commands( excepted_state: str, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test switch commands.""" entity_id = "switch.test_mower_1_avoid_danger_zone" @@ -124,8 +129,11 @@ async def test_stay_out_zone_switch_commands( domain="switch", service=service, service_data={"entity_id": entity_id}, - blocking=True, + 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_ZONE_ID, boolean) state = hass.states.get(entity_id) assert state is not None From b973455037a163b9753df3b493c82b545dac74f4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 16:28:55 +0200 Subject: [PATCH 23/42] Fix template image test affecting other tests (#122849) --- tests/components/template/test_image.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py index d4e98d7a3ca10..101b475956a20 100644 --- a/tests/components/template/test_image.py +++ b/tests/components/template/test_image.py @@ -76,10 +76,12 @@ async def _assert_state( assert body == expected_image +@respx.mock @pytest.mark.freeze_time("2024-07-09 00:00:00+00:00") async def test_setup_config_entry( hass: HomeAssistant, snapshot: SnapshotAssertion, + imgbytes_jpg, ) -> None: """Test the config flow.""" @@ -538,6 +540,7 @@ async def test_trigger_image_custom_entity_picture( ) +@respx.mock async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 1382f7a3dc12423ffb2f9a38bd76a72a799ae222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 16:29:59 +0200 Subject: [PATCH 24/42] Fix generic IP camera tests affecting other tests (#122858) --- tests/components/generic/conftest.py | 17 +++++++++++++---- tests/components/generic/test_config_flow.py | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 34062aab9545d..69e6cc6b69600 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from io import BytesIO from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch @@ -51,15 +52,23 @@ def fakeimgbytes_gif() -> bytes: @pytest.fixture -def fakeimg_png(fakeimgbytes_png: bytes) -> None: +def fakeimg_png(fakeimgbytes_png: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) + respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + stream=fakeimgbytes_png + ) + yield + respx.pop("fake_img") @pytest.fixture -def fakeimg_gif(fakeimgbytes_gif: bytes) -> None: +def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: """Set up respx to respond to test url with fake image bytes.""" - respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_gif) + respx.get("http://127.0.0.1/testurl/1", name="fake_img").respond( + stream=fakeimgbytes_gif + ) + yield + respx.pop("fake_img") @pytest.fixture(scope="package") diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 456e41a8d6097..e7af938379161 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -638,6 +638,7 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None: @respx.mock +@pytest.mark.usefixtures("fakeimg_png") async def test_form_stream_worker_error( hass: HomeAssistant, user_flow: ConfigFlowResult ) -> None: From 4994e46ad04fec84f7e2aed0f167796ab158d192 Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Tue, 30 Jul 2024 16:44:04 +0200 Subject: [PATCH 25/42] Add mdi:alert-circle-outline to degrade status (#122859) --- homeassistant/components/synology_dsm/icons.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/icons.json b/homeassistant/components/synology_dsm/icons.json index 8b4fad457d52e..8e6d2b17f02b1 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -50,7 +50,10 @@ "default": "mdi:download" }, "volume_status": { - "default": "mdi:checkbox-marked-circle-outline" + "default": "mdi:checkbox-marked-circle-outline", + "state": { + "degrade": "mdi:alert-circle-outline" + } }, "volume_size_total": { "default": "mdi:chart-pie" From b3f7f379df235bcd7ebdd2b039932c98e2dff937 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 16:51:02 +0200 Subject: [PATCH 26/42] Upgrade dsmr-parser to 1.4.2 (#121929) --- homeassistant/components/dsmr/manifest.json | 2 +- homeassistant/components/dsmr/sensor.py | 141 ++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dsmr/test_mbus_migration.py | 30 +-- tests/components/dsmr/test_sensor.py | 183 ++++++++++--------- 6 files changed, 195 insertions(+), 165 deletions(-) diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index c8f0a78f4dcd5..5490b2a650385 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==1.3.1"] + "requirements": ["dsmr-parser==1.4.2"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index ae7b08b7f62fc..f794d1d05e9df 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -4,10 +4,11 @@ import asyncio from asyncio import CancelledError -from collections.abc import Callable +from collections.abc import Callable, Generator from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +from enum import IntEnum from functools import partial from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader @@ -15,7 +16,7 @@ create_rfxtrx_dsmr_reader, create_rfxtrx_tcp_dsmr_reader, ) -from dsmr_parser.objects import DSMRObject, Telegram +from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram import serial from homeassistant.components.sensor import ( @@ -77,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference: str +class MbusDeviceType(IntEnum): + """Types of mbus devices (13757-3:2013).""" + + GAS = 3 + WATER = 7 + + SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="timestamp", @@ -318,7 +326,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): DSMRSensorEntityDescription( key="belgium_max_current_per_phase", translation_key="max_current_per_phase", - obis_reference="BELGIUM_MAX_CURRENT_PER_PHASE", + obis_reference="FUSE_THRESHOLD_L1", dsmr_versions={"5B"}, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -377,38 +385,36 @@ class DSMRSensorEntityDescription(SensorEntityDescription): ), ) - -def create_mbus_entity( - mbus: int, mtype: int, telegram: Telegram -) -> DSMRSensorEntityDescription | None: - """Create a new MBUS Entity.""" - if mtype == 3 and hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING2"): - return DSMRSensorEntityDescription( - key=f"mbus{mbus}_gas_reading", +SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = { + MbusDeviceType.GAS: ( + DSMRSensorEntityDescription( + key="gas_reading", translation_key="gas_meter_reading", - obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING2", + obis_reference="MBUS_METER_READING", is_gas=True, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, - ) - if mtype == 7 and (hasattr(telegram, f"BELGIUM_MBUS{mbus}_METER_READING1")): - return DSMRSensorEntityDescription( - key=f"mbus{mbus}_water_reading", + ), + ), + MbusDeviceType.WATER: ( + DSMRSensorEntityDescription( + key="water_reading", translation_key="water_meter_reading", - obis_reference=f"BELGIUM_MBUS{mbus}_METER_READING1", + obis_reference="MBUS_METER_READING", is_water=True, device_class=SensorDeviceClass.WATER, state_class=SensorStateClass.TOTAL_INCREASING, - ) - return None + ), + ), +} def device_class_and_uom( - telegram: dict[str, DSMRObject], + data: Telegram | MbusDevice, entity_description: DSMRSensorEntityDescription, ) -> tuple[SensorDeviceClass | None, str | None]: """Get native unit of measurement from telegram,.""" - dsmr_object = getattr(telegram, entity_description.obis_reference) + dsmr_object = getattr(data, entity_description.obis_reference) uom: str | None = getattr(dsmr_object, "unit") or None with suppress(ValueError): if entity_description.device_class == SensorDeviceClass.GAS and ( @@ -460,37 +466,60 @@ def rename_old_gas_to_mbus( dev_reg.async_remove_device(device_id) +def is_supported_description( + data: Telegram | MbusDevice, + description: DSMRSensorEntityDescription, + dsmr_version: str, +) -> bool: + """Check if this is a supported description for this telegram.""" + return hasattr(data, description.obis_reference) and ( + description.dsmr_versions is None or dsmr_version in description.dsmr_versions + ) + + def create_mbus_entities( - hass: HomeAssistant, telegram: Telegram, entry: ConfigEntry -) -> list[DSMREntity]: + hass: HomeAssistant, telegram: Telegram, entry: ConfigEntry, dsmr_version: str +) -> Generator[DSMREntity]: """Create MBUS Entities.""" - entities = [] - for idx in range(1, 5): - if ( - device_type := getattr(telegram, f"BELGIUM_MBUS{idx}_DEVICE_TYPE", None) - ) is None: - continue - if (type_ := int(device_type.value)) not in (3, 7): + mbus_devices: list[MbusDevice] = getattr(telegram, "MBUS_DEVICES", []) + for device in mbus_devices: + if (device_type := getattr(device, "MBUS_DEVICE_TYPE", None)) is None: continue - if identifier := getattr( - telegram, f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", None - ): + type_ = int(device_type.value) + + if identifier := getattr(device, "MBUS_EQUIPMENT_IDENTIFIER", None): serial_ = identifier.value rename_old_gas_to_mbus(hass, entry, serial_) else: serial_ = "" - if description := create_mbus_entity(idx, type_, telegram): - entities.append( - DSMREntity( - description, - entry, - telegram, - *device_class_and_uom(telegram, description), # type: ignore[arg-type] - serial_, - idx, - ) + + for description in SENSORS_MBUS_DEVICE_TYPE.get(type_, ()): + if not is_supported_description(device, description, dsmr_version): + continue + yield DSMREntity( + description, + entry, + telegram, + *device_class_and_uom(device, description), # type: ignore[arg-type] + serial_, + device.channel_id, ) - return entities + + +def get_dsmr_object( + telegram: Telegram | None, mbus_id: int, obis_reference: str +) -> DSMRObject | None: + """Extract DSMR object from telegram.""" + if not telegram: + return None + + telegram_or_device: Telegram | MbusDevice | None = telegram + if mbus_id: + telegram_or_device = telegram.get_mbus_device_by_channel(mbus_id) + if telegram_or_device is None: + return None + + return getattr(telegram_or_device, obis_reference, None) async def async_setup_entry( @@ -510,8 +539,7 @@ def init_async_add_entities(telegram: Telegram) -> None: add_entities_handler() add_entities_handler = None - if dsmr_version == "5B": - entities.extend(create_mbus_entities(hass, telegram, entry)) + entities.extend(create_mbus_entities(hass, telegram, entry, dsmr_version)) entities.extend( [ @@ -522,12 +550,8 @@ def init_async_add_entities(telegram: Telegram) -> None: *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) for description in SENSORS - if ( - description.dsmr_versions is None - or dsmr_version in description.dsmr_versions - ) + if is_supported_description(telegram, description, dsmr_version) and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) - and hasattr(telegram, description.obis_reference) ] ) async_add_entities(entities) @@ -723,6 +747,7 @@ def __init__( identifiers={(DOMAIN, device_serial)}, name=device_name, ) + self._mbus_id = mbus_id if mbus_id != 0: if serial_id: self._attr_unique_id = f"{device_serial}" @@ -737,20 +762,22 @@ def update_data(self, telegram: Telegram | None) -> None: self.telegram = telegram if self.hass and ( telegram is None - or hasattr(telegram, self.entity_description.obis_reference) + or get_dsmr_object( + telegram, self._mbus_id, self.entity_description.obis_reference + ) ): self.async_write_ha_state() def get_dsmr_object_attr(self, attribute: str) -> str | None: """Read attribute from last received telegram for this DSMR object.""" - # Make sure telegram contains an object for this entities obis - if self.telegram is None or not hasattr( - self.telegram, self.entity_description.obis_reference - ): + # Get the object + dsmr_object = get_dsmr_object( + self.telegram, self._mbus_id, self.entity_description.obis_reference + ) + if dsmr_object is None: return None # Get the attribute value if the object has it - dsmr_object = getattr(self.telegram, self.entity_description.obis_reference) attr: str | None = getattr(dsmr_object, attribute) return attr diff --git a/requirements_all.txt b/requirements_all.txt index 7a63bcb560209..4d74e059a5927 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -750,7 +750,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.3.1 +dsmr-parser==1.4.2 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e8664f482179..86fdf2da7f98d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dremel3dpy==2.1.1 dropmqttapi==1.0.3 # homeassistant.components.dsmr -dsmr-parser==1.3.1 +dsmr-parser==1.4.2 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index cd3db27be8c54..a28bc2c3a3385 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -5,9 +5,9 @@ from unittest.mock import MagicMock from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING2, + MBUS_DEVICE_TYPE, + MBUS_EQUIPMENT_IDENTIFIER, + MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram @@ -67,20 +67,20 @@ async def test_migrate_gas_to_mbus( telegram = Telegram() telegram.add( - BELGIUM_MBUS1_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 1), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS1_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 1), [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - "BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS1_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 1), [ @@ -88,7 +88,7 @@ async def test_migrate_gas_to_mbus( {"value": Decimal(745.695), "unit": "m3"}, ], ), - "BELGIUM_MBUS1_METER_READING2", + "MBUS_METER_READING", ) assert await hass.config_entries.async_setup(mock_entry.entry_id) @@ -184,20 +184,20 @@ async def test_migrate_gas_to_mbus_exists( telegram = Telegram() telegram.add( - BELGIUM_MBUS1_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 0), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS1_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 1), [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - "BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS1_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 1), [ @@ -205,7 +205,7 @@ async def test_migrate_gas_to_mbus_exists( {"value": Decimal(745.695), "unit": "m3"}, ], ), - "BELGIUM_MBUS1_METER_READING2", + "MBUS_METER_READING", ) assert await hass.config_entries.async_setup(mock_entry.entry_id) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 5b0cf6d7a156e..b93dd8d18d2b9 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -15,33 +15,20 @@ from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_DEVICE_TYPE, - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_DEVICE_TYPE, - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_DEVICE_TYPE, - BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS4_METER_READING2, CURRENT_ELECTRICITY_USAGE, ELECTRICITY_ACTIVE_TARIFF, ELECTRICITY_EXPORTED_TOTAL, ELECTRICITY_IMPORTED_TOTAL, GAS_METER_READING, HOURLY_GAS_METER_READING, + MBUS_DEVICE_TYPE, + MBUS_EQUIPMENT_IDENTIFIER, + MBUS_METER_READING, ) from dsmr_parser.objects import CosemObject, MBusObject, Telegram import pytest -from homeassistant.components.dsmr.sensor import SENSORS +from homeassistant.components.dsmr.sensor import SENSORS, SENSORS_MBUS_DEVICE_TYPE from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -562,20 +549,20 @@ async def test_belgian_meter( "BELGIUM_MAXIMUM_DEMAND_MONTH", ) telegram.add( - BELGIUM_MBUS1_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 1), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS1_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 1), [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - "BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS1_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 1), [ @@ -583,23 +570,23 @@ async def test_belgian_meter( {"value": Decimal(745.695), "unit": "m3"}, ], ), - "BELGIUM_MBUS1_METER_READING2", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS2_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 2), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS2_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 2), [{"value": "37464C4F32313139303333373332", "unit": ""}], ), - "BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS2_METER_READING1, + MBUS_METER_READING, MBusObject( (0, 2), [ @@ -607,23 +594,23 @@ async def test_belgian_meter( {"value": Decimal(678.695), "unit": "m3"}, ], ), - "BELGIUM_MBUS2_METER_READING1", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS3_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 3), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS3_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 3), [{"value": "37464C4F32313139303333373333", "unit": ""}], ), - "BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS3_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 3), [ @@ -631,23 +618,23 @@ async def test_belgian_meter( {"value": Decimal(12.12), "unit": "m3"}, ], ), - "BELGIUM_MBUS3_METER_READING2", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS4_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 4), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS4_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 4), [{"value": "37464C4F32313139303333373334", "unit": ""}], ), - "BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS4_METER_READING1, + MBUS_METER_READING, MBusObject( (0, 4), [ @@ -655,7 +642,7 @@ async def test_belgian_meter( {"value": Decimal(13.13), "unit": "m3"}, ], ), - "BELGIUM_MBUS4_METER_READING1", + "MBUS_METER_READING", ) telegram.add( ELECTRICITY_ACTIVE_TARIFF, @@ -777,20 +764,20 @@ async def test_belgian_meter_alt( telegram = Telegram() telegram.add( - BELGIUM_MBUS1_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 1), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS1_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 1), [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - "BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS1_METER_READING1, + MBUS_METER_READING, MBusObject( (0, 1), [ @@ -798,23 +785,23 @@ async def test_belgian_meter_alt( {"value": Decimal(123.456), "unit": "m3"}, ], ), - "BELGIUM_MBUS1_METER_READING1", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS2_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 2), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS2_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 2), [{"value": "37464C4F32313139303333373332", "unit": ""}], ), - "BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS2_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 2), [ @@ -822,23 +809,23 @@ async def test_belgian_meter_alt( {"value": Decimal(678.901), "unit": "m3"}, ], ), - "BELGIUM_MBUS2_METER_READING2", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS3_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 3), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS3_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 3), [{"value": "37464C4F32313139303333373333", "unit": ""}], ), - "BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS3_METER_READING1, + MBUS_METER_READING, MBusObject( (0, 3), [ @@ -846,23 +833,23 @@ async def test_belgian_meter_alt( {"value": Decimal(12.12), "unit": "m3"}, ], ), - "BELGIUM_MBUS3_METER_READING1", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS4_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 4), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS4_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 4), [{"value": "37464C4F32313139303333373334", "unit": ""}], ), - "BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS4_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 4), [ @@ -870,7 +857,7 @@ async def test_belgian_meter_alt( {"value": Decimal(13.13), "unit": "m3"}, ], ), - "BELGIUM_MBUS4_METER_READING2", + "MBUS_METER_READING", ) mock_entry = MockConfigEntry( @@ -970,46 +957,46 @@ async def test_belgian_meter_mbus( "ELECTRICITY_ACTIVE_TARIFF", ) telegram.add( - BELGIUM_MBUS1_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 1), [{"value": "006", "unit": ""}]), - "BELGIUM_MBUS1_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 1), [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - "BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS2_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 2), [{"value": "003", "unit": ""}]), - "BELGIUM_MBUS2_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 2), [{"value": "37464C4F32313139303333373332", "unit": ""}], ), - "BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS3_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 3), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS3_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + MBUS_EQUIPMENT_IDENTIFIER, CosemObject( (0, 3), [{"value": "37464C4F32313139303333373333", "unit": ""}], ), - "BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER", + "MBUS_EQUIPMENT_IDENTIFIER", ) telegram.add( - BELGIUM_MBUS3_METER_READING2, + MBUS_METER_READING, MBusObject( (0, 3), [ @@ -1017,15 +1004,15 @@ async def test_belgian_meter_mbus( {"value": Decimal(12.12), "unit": "m3"}, ], ), - "BELGIUM_MBUS3_METER_READING2", + "MBUS_METER_READING", ) telegram.add( - BELGIUM_MBUS4_DEVICE_TYPE, + MBUS_DEVICE_TYPE, CosemObject((0, 4), [{"value": "007", "unit": ""}]), - "BELGIUM_MBUS4_DEVICE_TYPE", + "MBUS_DEVICE_TYPE", ) telegram.add( - BELGIUM_MBUS4_METER_READING1, + MBUS_METER_READING, MBusObject( (0, 4), [ @@ -1033,7 +1020,7 @@ async def test_belgian_meter_mbus( {"value": Decimal(13.13), "unit": "m3"}, ], ), - "BELGIUM_MBUS4_METER_READING1", + "MBUS_METER_READING", ) mock_entry = MockConfigEntry( @@ -1057,20 +1044,32 @@ async def test_belgian_meter_mbus( active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") assert active_tariff.state == "unknown" - # check if gas consumption mbus2 is parsed correctly + # check if gas consumption mbus1 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption is None - # check if water usage mbus3 is parsed correctly - water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") - assert water_consumption is None - - # check if gas consumption mbus4 is parsed correctly + # check if gas consumption mbus2 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") assert gas_consumption is None - # check if gas consumption mbus4 is parsed correctly + # check if water usage mbus3 is parsed correctly water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption + assert water_consumption.state == "12.12" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus4 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") assert water_consumption.state == "13.13" assert ( water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER @@ -1526,3 +1525,7 @@ def test_all_obis_references_exists(): """Verify that all attributes exist by name in database.""" for sensor in SENSORS: assert hasattr(obis_references, sensor.obis_reference) + + for sensors in SENSORS_MBUS_DEVICE_TYPE.values(): + for sensor in sensors: + assert hasattr(obis_references, sensor.obis_reference) From 4a34855a921fd6d6d7519ff2ef721e3cb72f31e6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 16:57:42 +0200 Subject: [PATCH 27/42] Fix implicit-return in scripts (#122831) --- script/install_integration_requirements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index ab91ea715577c..91c9f6a8ed04a 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -45,6 +45,7 @@ def main() -> int | None: cmd, check=True, ) + return None if __name__ == "__main__": From 6840f27bc6217ea57c1617261a320c624427acbf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 17:12:58 +0200 Subject: [PATCH 28/42] Verify respx mock routes are cleaned up when tests finish (#122852) --- tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 0d0fd826b44dc..0667edf4be2e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ import pytest import pytest_socket import requests_mock +import respx from syrupy.assertion import SnapshotAssertion from homeassistant import block_async_io @@ -398,6 +399,13 @@ def verify_cleanup( # Restore the default time zone to not break subsequent tests dt_util.DEFAULT_TIME_ZONE = datetime.UTC + try: + # Verify respx.mock has been cleaned up + assert not respx.mock.routes, "respx.mock routes not cleaned up, maybe the test needs to be decorated with @respx.mock" + finally: + # Clear mock routes not break subsequent tests + respx.mock.clear() + @pytest.fixture(autouse=True) def reset_hass_threading_local_object() -> Generator[None]: From b69b927795e4ede255b3f323e3594ce0a06acf6f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 30 Jul 2024 17:17:20 +0200 Subject: [PATCH 29/42] Set parallel updates in devolo_home_network (#122847) --- homeassistant/components/devolo_home_network/binary_sensor.py | 2 ++ homeassistant/components/devolo_home_network/button.py | 2 ++ homeassistant/components/devolo_home_network/device_tracker.py | 2 ++ homeassistant/components/devolo_home_network/image.py | 2 ++ homeassistant/components/devolo_home_network/sensor.py | 2 ++ homeassistant/components/devolo_home_network/switch.py | 2 ++ homeassistant/components/devolo_home_network/update.py | 2 ++ 7 files changed, 14 insertions(+) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 38d799511496e..c96d0273a50e1 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -21,6 +21,8 @@ from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER from .entity import DevoloCoordinatorEntity +PARALLEL_UPDATES = 1 + def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: """Check, if device is attached to the router.""" diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 1f67912f0206f..ca17b5725222c 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -22,6 +22,8 @@ from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS from .entity import DevoloEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class DevoloButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 0a221779622a9..960069191eec4 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -22,6 +22,8 @@ from . import DevoloHomeNetworkConfigEntry from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index ee3b079da02d2..58052d3021e7c 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -20,6 +20,8 @@ from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI from .entity import DevoloCoordinatorEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class DevoloImageEntityDescription(ImageEntityDescription): diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index ffd40acf42af4..2fd8ab9220cff 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -31,6 +31,8 @@ ) from .entity import DevoloCoordinatorEntity +PARALLEL_UPDATES = 1 + _CoordinatorDataT = TypeVar( "_CoordinatorDataT", bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 3df67287f3bcc..c3400916d78ea 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -21,6 +21,8 @@ from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS from .entity import DevoloCoordinatorEntity +PARALLEL_UPDATES = 1 + _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 92f5cb0f09429..29c0c8762b907 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -26,6 +26,8 @@ from .const import DOMAIN, REGULAR_FIRMWARE from .entity import DevoloCoordinatorEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class DevoloUpdateEntityDescription(UpdateEntityDescription): From 1ffde403f020e3aafe21e1fe09e9ffa9bff3d5a2 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 30 Jul 2024 16:18:33 +0100 Subject: [PATCH 30/42] Ensure evohome leaves no lingering timers (#122860) --- homeassistant/components/evohome/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2df4ae1be6b93..5a5d9d0952100 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -243,6 +243,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], update_method=broker.async_update, ) + await coordinator.async_register_shutdown() hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator} From 896cd27bea14fdb11d3bde57d7a2902567b85342 Mon Sep 17 00:00:00 2001 From: Kim de Vos Date: Tue, 30 Jul 2024 17:20:56 +0200 Subject: [PATCH 31/42] Add sensors for Unifi latency (#116737) * Add sensors for Unifi latency * Add needed guard and casting * Use new types * Add WAN2 support * Add literals * Make ids for WAN and WAN2 unique * Make methods general * Update sensor.py * add more typing * Simplify usage make_wan_latency_sensors * Simplify further * Move latency entity creation to method * Make method internal * simplify tests * Apply feedback * Reduce boiler copied code and add support function * Add external method for share logic between * Remove none * Add more tests * Apply feedback and change code to improve code coverage --- homeassistant/components/unifi/sensor.py | 93 +++++++++- tests/components/unifi/test_sensor.py | 224 +++++++++++++++++++++++ 2 files changed, 315 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index bb974864f601f..d86b72d1b2f1f 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -11,6 +11,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from functools import partial +from typing import TYPE_CHECKING, Literal from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients @@ -20,7 +21,7 @@ from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client -from aiounifi.models.device import Device +from aiounifi.models.device import Device, TypedDeviceUptimeStatsWanMonitor from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.wlan import Wlan @@ -32,7 +33,13 @@ SensorStateClass, UnitOfTemperature, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfDataRate, + UnitOfPower, + UnitOfTime, +) 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 @@ -192,6 +199,86 @@ def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str: return DEVICE_STATES[device.state] +@callback +def async_device_wan_latency_supported_fn( + wan: Literal["WAN", "WAN2"], + monitor_target: str, + hub: UnifiHub, + obj_id: str, +) -> bool: + """Determine if an device have a latency monitor.""" + if (device := hub.api.devices[obj_id]) and device.uptime_stats: + return _device_wan_latency_monitor(wan, monitor_target, device) is not None + return False + + +@callback +def async_device_wan_latency_value_fn( + wan: Literal["WAN", "WAN2"], + monitor_target: str, + hub: UnifiHub, + device: Device, +) -> int | None: + """Retrieve the monitor target from WAN monitors.""" + target = _device_wan_latency_monitor(wan, monitor_target, device) + + if TYPE_CHECKING: + # Checked by async_device_wan_latency_supported_fn + assert target + + return target.get("latency_average", 0) + + +@callback +def _device_wan_latency_monitor( + wan: Literal["WAN", "WAN2"], monitor_target: str, device: Device +) -> TypedDeviceUptimeStatsWanMonitor | None: + """Return the target of the WAN latency monitor.""" + if device.uptime_stats and (uptime_stats_wan := device.uptime_stats.get(wan)): + for monitor in uptime_stats_wan["monitors"]: + if monitor_target in monitor["target"]: + return monitor + return None + + +def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]: + """Create WAN latency sensors from WAN monitor data.""" + + def make_wan_latency_entity_description( + wan: Literal["WAN", "WAN2"], name: str, monitor_target: str + ) -> UnifiSensorEntityDescription: + return UnifiSensorEntityDescription[Devices, Device]( + key=f"{name} {wan} latency", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + name_fn=lambda _: f"{name} {wan} latency", + object_fn=lambda api, obj_id: api.devices[obj_id], + supported_fn=partial( + async_device_wan_latency_supported_fn, wan, monitor_target + ), + unique_id_fn=lambda hub, + obj_id: f"{name.lower}_{wan.lower}_latency-{obj_id}", + value_fn=partial(async_device_wan_latency_value_fn, wan, monitor_target), + ) + + wans: tuple[Literal["WAN"], Literal["WAN2"]] = ("WAN", "WAN2") + return tuple( + make_wan_latency_entity_description(wan, name, target) + for wan in wans + for name, target in ( + ("Microsoft", "microsoft"), + ("Google", "google"), + ("Cloudflare", "1.1.1.1"), + ) + ) + + @dataclass(frozen=True, kw_only=True) class UnifiSensorEntityDescription( SensorEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] @@ -456,6 +543,8 @@ class UnifiSensorEntityDescription( ), ) +ENTITY_DESCRIPTIONS += make_wan_latency_sensors() + async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index e1893922f6075..779df6660f063 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1424,3 +1424,227 @@ async def test_device_uptime( entity_registry.async_get("sensor.device_uptime").entity_category is EntityCategory.DIAGNOSTIC ) + + +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "uptime_stats": { + "WAN": { + "availability": 100.0, + "latency_average": 39, + "monitors": [ + { + "availability": 100.0, + "latency_average": 56, + "target": "www.microsoft.com", + "type": "icmp", + }, + { + "availability": 100.0, + "latency_average": 53, + "target": "google.com", + "type": "icmp", + }, + { + "availability": 100.0, + "latency_average": 30, + "target": "1.1.1.1", + "type": "icmp", + }, + ], + }, + "WAN2": { + "monitors": [ + { + "availability": 0.0, + "target": "www.microsoft.com", + "type": "icmp", + }, + { + "availability": 0.0, + "target": "google.com", + "type": "icmp", + }, + {"availability": 0.0, "target": "1.1.1.1", "type": "icmp"}, + ], + }, + }, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.parametrize( + ("entity_id", "state", "updated_state", "index_to_update"), + [ + # Microsoft + ("microsoft_wan", "56", "20", 0), + # Google + ("google_wan", "53", "90", 1), + # Cloudflare + ("cloudflare_wan", "30", "80", 2), + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_wan_monitor_latency( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_websocket_message, + device_payload: list[dict[str, Any]], + entity_id: str, + state: str, + updated_state: str, + index_to_update: int, +) -> None: + """Verify that wan latency sensors are working as expected.""" + + assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + + latency_entry = entity_registry.async_get(f"sensor.mock_name_{entity_id}_latency") + assert latency_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert latency_entry.entity_category is EntityCategory.DIAGNOSTIC + + # Enable entity + entity_registry.async_update_entity( + entity_id=f"sensor.mock_name_{entity_id}_latency", disabled_by=None + ) + + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 7 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + # Verify sensor attributes and state + latency_entry = hass.states.get(f"sensor.mock_name_{entity_id}_latency") + assert latency_entry.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION + assert ( + latency_entry.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + ) + assert latency_entry.state == state + + # Verify state update + device = device_payload[0] + device["uptime_stats"]["WAN"]["monitors"][index_to_update]["latency_average"] = ( + updated_state + ) + + mock_websocket_message(message=MessageKey.DEVICE, data=device) + + assert ( + hass.states.get(f"sensor.mock_name_{entity_id}_latency").state == updated_state + ) + + +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "uptime_stats": { + "WAN": { + "monitors": [ + { + "availability": 100.0, + "latency_average": 30, + "target": "1.2.3.4", + "type": "icmp", + }, + ], + }, + "WAN2": { + "monitors": [ + { + "availability": 0.0, + "target": "www.microsoft.com", + "type": "icmp", + }, + { + "availability": 0.0, + "target": "google.com", + "type": "icmp", + }, + {"availability": 0.0, "target": "1.1.1.1", "type": "icmp"}, + ], + }, + }, + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_wan_monitor_latency_with_no_entries( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Verify that wan latency sensors is not created if there is no data.""" + + assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + + latency_entry = entity_registry.async_get("sensor.mock_name_google_wan_latency") + assert latency_entry is None + + +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_wan_monitor_latency_with_no_uptime( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Verify that wan latency sensors is not created if there is no data.""" + + assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + + latency_entry = entity_registry.async_get("sensor.mock_name_google_wan_latency") + assert latency_entry is None From 8066c7dec60e7164001bfff952d68517d1477c65 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:21:45 +0200 Subject: [PATCH 32/42] Fix implicit-return in deconz (#122836) --- homeassistant/components/deconz/fan.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index dc65756eeebe6..67c759afeda51 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -95,7 +95,8 @@ def async_update_callback(self) -> None: async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" if percentage == 0: - return await self.async_turn_off() + await self.async_turn_off() + return await self.hub.api.lights.lights.set_state( id=self._device.resource_id, fan_speed=percentage_to_ordered_list_item( From be24475cee71d3369d3975da12afd3709d47056b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 30 Jul 2024 18:24:03 +0300 Subject: [PATCH 33/42] Update selector converters for llm script tools (#122830) --- homeassistant/helpers/llm.py | 4 +- tests/helpers/test_llm.py | 73 ++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 4c8e2df06a43f..8ad576b7ea5ea 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -521,7 +521,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return convert(cv.CONDITIONS_SCHEMA) if isinstance(schema, selector.ConstantSelector): - return {"enum": [schema.config["value"]]} + return convert(vol.Schema(schema.config["value"])) result: dict[str, Any] if isinstance(schema, selector.ColorTempSelector): @@ -573,7 +573,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return result if isinstance(schema, selector.ObjectSelector): - return {"type": "object"} + return {"type": "object", "additionalProperties": True} if isinstance(schema, selector.SelectSelector): options = [ diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3ad5b23b731f2..ea6e628d1d4a0 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -842,13 +842,22 @@ async def test_selector_serializer( assert selector_serializer( selector.ColorTempSelector({"min_mireds": 100, "max_mireds": 1000}) ) == {"type": "number", "minimum": 100, "maximum": 1000} + assert selector_serializer(selector.ConditionSelector()) == { + "type": "array", + "items": {"nullable": True, "type": "string"}, + } assert selector_serializer(selector.ConfigEntrySelector()) == {"type": "string"} assert selector_serializer(selector.ConstantSelector({"value": "test"})) == { - "enum": ["test"] + "type": "string", + "enum": ["test"], + } + assert selector_serializer(selector.ConstantSelector({"value": 1})) == { + "type": "integer", + "enum": [1], } - assert selector_serializer(selector.ConstantSelector({"value": 1})) == {"enum": [1]} assert selector_serializer(selector.ConstantSelector({"value": True})) == { - "enum": [True] + "type": "boolean", + "enum": [True], } assert selector_serializer(selector.QrCodeSelector({"data": "test"})) == { "type": "string" @@ -876,6 +885,17 @@ async def test_selector_serializer( "type": "array", "items": {"type": "string"}, } + assert selector_serializer(selector.DurationSelector()) == { + "type": "object", + "properties": { + "days": {"type": "number"}, + "hours": {"type": "number"}, + "minutes": {"type": "number"}, + "seconds": {"type": "number"}, + "milliseconds": {"type": "number"}, + }, + "required": [], + } assert selector_serializer(selector.EntitySelector()) == { "type": "string", "format": "entity_id", @@ -929,7 +949,10 @@ async def test_selector_serializer( "minimum": 30, "maximum": 100, } - assert selector_serializer(selector.ObjectSelector()) == {"type": "object"} + assert selector_serializer(selector.ObjectSelector()) == { + "type": "object", + "additionalProperties": True, + } assert selector_serializer( selector.SelectSelector( { @@ -951,6 +974,48 @@ async def test_selector_serializer( assert selector_serializer( selector.StateSelector({"entity_id": "sensor.test"}) ) == {"type": "string"} + target_schema = selector_serializer(selector.TargetSelector()) + target_schema["properties"]["entity_id"]["anyOf"][0][ + "enum" + ].sort() # Order is not deterministic + assert target_schema == { + "type": "object", + "properties": { + "area_id": { + "anyOf": [ + {"type": "string", "enum": ["none"]}, + {"type": "array", "items": {"type": "string", "nullable": True}}, + ] + }, + "device_id": { + "anyOf": [ + {"type": "string", "enum": ["none"]}, + {"type": "array", "items": {"type": "string", "nullable": True}}, + ] + }, + "entity_id": { + "anyOf": [ + {"type": "string", "enum": ["all", "none"], "format": "lower"}, + {"type": "string", "nullable": True}, + {"type": "array", "items": {"type": "string"}}, + ] + }, + "floor_id": { + "anyOf": [ + {"type": "string", "enum": ["none"]}, + {"type": "array", "items": {"type": "string", "nullable": True}}, + ] + }, + "label_id": { + "anyOf": [ + {"type": "string", "enum": ["none"]}, + {"type": "array", "items": {"type": "string", "nullable": True}}, + ] + }, + }, + "required": [], + } + assert selector_serializer(selector.TemplateSelector()) == { "type": "string", "format": "jinja2", From 18a7d15d14bea31f340611a86c096f143cce0fd9 Mon Sep 17 00:00:00 2001 From: bdowden Date: Tue, 30 Jul 2024 11:26:08 -0400 Subject: [PATCH 34/42] Add Traffic Rule switches to UniFi Network (#118821) * Add Traffic Rule switches to UniFi Network * Retrieve Fix unifi traffic rule switches Poll for traffic rule updates; have immediate feedback in the UI for modifying traffic rules * Remove default values for unifi entity; Remove unnecessary code * Begin updating traffic rule unit tests * For the mock get request, allow for meta and data properties to not be appended to support v2 api requests Fix traffic rule unit tests; * inspect path to determine json response instead of passing an argument * Remove entity id parameter from tests; remove unused code; rename traffic rule unique ID prefix * Remove parameter with default. * More code removal; * Rename copy/paste variable; remove commented code; remove duplicate default code --------- Co-authored-by: ViViDboarder --- .../components/unifi/hub/entity_loader.py | 34 ++++++- homeassistant/components/unifi/switch.py | 31 ++++++- tests/components/unifi/conftest.py | 18 +++- tests/components/unifi/test_switch.py | 89 +++++++++++++++++++ 4 files changed, 164 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 29448a4114aae..f11ddefec98a8 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -7,9 +7,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine, Sequence from datetime import timedelta from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiounifi.interfaces.api_handlers import ItemEvent @@ -18,6 +19,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS from ..entity import UnifiEntity, UnifiEntityDescription @@ -26,6 +28,7 @@ from .hub import UnifiHub CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) +POLL_INTERVAL = timedelta(seconds=10) class UnifiEntityLoader: @@ -43,10 +46,24 @@ def __init__(self, hub: UnifiHub) -> None: hub.api.port_forwarding.update, hub.api.sites.update, hub.api.system_information.update, + hub.api.traffic_rules.update, hub.api.wlans.update, ) + self.polling_api_updaters = (hub.api.traffic_rules.update,) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] + self._dataUpdateCoordinator = DataUpdateCoordinator( + hub.hass, + LOGGER, + name="Unifi entity poller", + update_method=self._update_pollable_api_data, + update_interval=POLL_INTERVAL, + ) + + self._update_listener = self._dataUpdateCoordinator.async_add_listener( + update_callback=lambda: None + ) + self.platforms: list[ tuple[ AddEntitiesCallback, @@ -65,16 +82,25 @@ async def initialize(self) -> None: self._restore_inactive_clients() self.wireless_clients.update_clients(set(self.hub.api.clients.values())) - async def _refresh_api_data(self) -> None: - """Refresh API data from network application.""" + async def _refresh_data( + self, updaters: Sequence[Callable[[], Coroutine[Any, Any, None]]] + ) -> None: results = await asyncio.gather( - *[update() for update in self.api_updaters], + *[update() for update in updaters], return_exceptions=True, ) for result in results: if result is not None: LOGGER.warning("Exception on update %s", result) + async def _update_pollable_api_data(self) -> None: + """Refresh API data for pollable updaters.""" + await self._refresh_data(self.polling_api_updaters) + + async def _refresh_api_data(self) -> None: + """Refresh API data from network application.""" + await self._refresh_data(self.api_updaters) + @callback def _restore_inactive_clients(self) -> None: """Restore inactive clients. diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index ef30abb934981..93a0c81a24ee4 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -20,6 +20,7 @@ from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest @@ -30,6 +31,7 @@ from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest +from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest from homeassistant.components.switch import ( @@ -94,8 +96,8 @@ def async_dpi_group_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: @callback -def async_port_forward_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: - """Create device registry entry for port forward.""" +def async_unifi_network_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: + """Create device registry entry for the UniFi Network application.""" unique_id = hub.config.entry.unique_id assert unique_id is not None return DeviceInfo( @@ -158,6 +160,16 @@ async def async_port_forward_control_fn( await hub.api.request(PortForwardEnableRequest.create(port_forward, target)) +async def async_traffic_rule_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control traffic rule state.""" + traffic_rule = hub.api.traffic_rules[obj_id].raw + await hub.api.request(TrafficRuleEnableRequest.create(traffic_rule, target)) + # Update the traffic rules so the UI is updated appropriately + await hub.api.traffic_rules.update() + + async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" await hub.api.request(WlanEnableRequest.create(obj_id, target)) @@ -232,12 +244,25 @@ class UnifiSwitchEntityDescription( icon="mdi:upload-network", api_handler_fn=lambda api: api.port_forwarding, control_fn=async_port_forward_control_fn, - device_info_fn=async_port_forward_device_info_fn, + device_info_fn=async_unifi_network_device_info_fn, is_on_fn=lambda hub, port_forward: port_forward.enabled, name_fn=lambda port_forward: f"{port_forward.name}", object_fn=lambda api, obj_id: api.port_forwarding[obj_id], unique_id_fn=lambda hub, obj_id: f"port_forward-{obj_id}", ), + UnifiSwitchEntityDescription[TrafficRules, TrafficRule]( + 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, + is_on_fn=lambda hub, traffic_rule: traffic_rule.enabled, + name_fn=lambda traffic_rule: traffic_rule.description, + object_fn=lambda api, obj_id: api.traffic_rules[obj_id], + unique_id_fn=lambda hub, obj_id: f"traffic_rule-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", device_class=SwitchDeviceClass.OUTLET, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index c20b8766bfc29..4e460bab8f823 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -160,6 +160,7 @@ def fixture_request( dpi_app_payload: list[dict[str, Any]], dpi_group_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], + traffic_rule_payload: list[dict[str, Any]], site_payload: list[dict[str, Any]], system_information_payload: list[dict[str, Any]], wlan_payload: list[dict[str, Any]], @@ -170,9 +171,16 @@ def __mock_requests(host: str = DEFAULT_HOST, site_id: str = DEFAULT_SITE) -> No url = f"https://{host}:{DEFAULT_PORT}" def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: + # APIV2 request respoonses have `meta` and `data` automatically appended + json = {} + if path.startswith("/v2"): + json = payload + else: + json = {"meta": {"rc": "OK"}, "data": payload} + aioclient_mock.get( f"{url}{path}", - json={"meta": {"rc": "OK"}, "data": payload}, + json=json, headers={"content-type": CONTENT_TYPE_JSON}, ) @@ -182,6 +190,7 @@ def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: json={"data": "login successful", "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + mock_get_request("/api/self/sites", site_payload) mock_get_request(f"/api/s/{site_id}/stat/sta", client_payload) mock_get_request(f"/api/s/{site_id}/rest/user", clients_all_payload) @@ -191,6 +200,7 @@ def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) + mock_get_request(f"/v2/api/site/{site_id}/trafficrules", traffic_rule_payload) return __mock_requests @@ -262,6 +272,12 @@ def fixture_system_information_data() -> list[dict[str, Any]]: ] +@pytest.fixture(name="traffic_rule_payload") +def traffic_rule_payload_data() -> list[dict[str, Any]]: + """Traffic rule data.""" + return [] + + @pytest.fixture(name="wlan_payload") def fixture_wlan_data() -> list[dict[str, Any]]: """WLAN data.""" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index b0ae8bde445b4..daf64301c8ead 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -774,6 +774,37 @@ "src": "any", } +TRAFFIC_RULE = { + "_id": "6452cd9b859d5b11aa002ea1", + "action": "BLOCK", + "app_category_ids": [], + "app_ids": [], + "bandwidth_limit": { + "download_limit_kbps": 1024, + "enabled": False, + "upload_limit_kbps": 1024, + }, + "description": "Test Traffic Rule", + "name": "Test Traffic Rule", + "domains": [], + "enabled": True, + "ip_addresses": [], + "ip_ranges": [], + "matching_target": "INTERNET", + "network_ids": [], + "regions": [], + "schedule": { + "date_end": "2023-05-10", + "date_start": "2023-05-03", + "mode": "ALWAYS", + "repeat_on_days": [], + "time_all_day": False, + "time_range_end": "12:00", + "time_range_start": "09:00", + }, + "target_devices": [{"client_mac": CLIENT_1["mac"], "type": "CLIENT"}], +} + @pytest.mark.parametrize("client_payload", [[CONTROLLER_HOST]]) @pytest.mark.parametrize("device_payload", [[DEVICE_1]]) @@ -1072,6 +1103,64 @@ async def test_dpi_switches_add_second_app( assert hass.states.get("switch.block_media_streaming").state == STATE_ON +@pytest.mark.parametrize(("traffic_rule_payload"), [([TRAFFIC_RULE])]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_traffic_rules( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_websocket_message, + config_entry_setup: ConfigEntry, + traffic_rule_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi traffic rules.""" + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + switch_1 = hass.states.get("switch.unifi_network_test_traffic_rule") + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH + + traffic_rule = deepcopy(traffic_rule_payload[0]) + + # Disable traffic rule + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}/trafficrules/{traffic_rule['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_test_traffic_rule"}, + blocking=True, + ) + # Updating the value for traffic rules will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(traffic_rule) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable traffic rule + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_test_traffic_rule"}, + blocking=True, + ) + + expected_enable_call = deepcopy(traffic_rule) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ From ea727546d645fd55b4f722c53c643fba1f1730bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B6rrle?= <7945681+CM000n@users.noreply.github.com> Date: Tue, 30 Jul 2024 18:11:08 +0200 Subject: [PATCH 35/42] Add apsystems power switch (#122447) * bring back power switch * fix pylint issues * add SWITCH to platform list * improve run_on and turn_off functions * ruff formatting * replace _state with _attr_is_on * Update homeassistant/components/apsystems/switch.py Co-authored-by: Joost Lekkerkerker * remove unused dependencies * Update homeassistant/components/apsystems/switch.py Co-authored-by: Joost Lekkerkerker * use async functions from api * convert Api IntEnum Status Information to bool * add translation key * implement async_update again * replace finally with else * better handling of bool value * Update homeassistant/components/apsystems/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/apsystems/switch.py Co-authored-by: Joost Lekkerkerker * rename power switch to inverter switch * add test_number and test_switch module * remove test_number * Add mock entry for get_device_power_status * Add mock entry for get_device_power_status * Update test snapshots --------- Co-authored-by: Joost Lekkerkerker --- .../components/apsystems/__init__.py | 2 +- .../components/apsystems/strings.json | 45 +++++++++++---- homeassistant/components/apsystems/switch.py | 56 +++++++++++++++++++ tests/components/apsystems/conftest.py | 3 +- .../apsystems/snapshots/test_switch.ambr | 48 ++++++++++++++++ tests/components/apsystems/test_switch.py | 31 ++++++++++ 6 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/apsystems/switch.py create mode 100644 tests/components/apsystems/snapshots/test_switch.ambr create mode 100644 tests/components/apsystems/test_switch.py diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index 40e62a32475d1..91650201a876e 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -13,7 +13,7 @@ from .const import DEFAULT_PORT from .coordinator import ApSystemsDataCoordinator -PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] @dataclass diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json index 95499e96b4d66..18200f7b49d0d 100644 --- a/homeassistant/components/apsystems/strings.json +++ b/homeassistant/components/apsystems/strings.json @@ -20,18 +20,43 @@ }, "entity": { "sensor": { - "total_power": { "name": "Total power" }, - "total_power_p1": { "name": "Power of P1" }, - "total_power_p2": { "name": "Power of P2" }, - "lifetime_production": { "name": "Total lifetime production" }, - "lifetime_production_p1": { "name": "Lifetime production of P1" }, - "lifetime_production_p2": { "name": "Lifetime production of P2" }, - "today_production": { "name": "Production of today" }, - "today_production_p1": { "name": "Production of today from P1" }, - "today_production_p2": { "name": "Production of today from P2" } + "total_power": { + "name": "Total power" + }, + "total_power_p1": { + "name": "Power of P1" + }, + "total_power_p2": { + "name": "Power of P2" + }, + "lifetime_production": { + "name": "Total lifetime production" + }, + "lifetime_production_p1": { + "name": "Lifetime production of P1" + }, + "lifetime_production_p2": { + "name": "Lifetime production of P2" + }, + "today_production": { + "name": "Production of today" + }, + "today_production_p1": { + "name": "Production of today from P1" + }, + "today_production_p2": { + "name": "Production of today from P2" + } }, "number": { - "max_output": { "name": "Max output" } + "max_output": { + "name": "Max output" + } + }, + "switch": { + "inverter_status": { + "name": "Inverter status" + } } } } diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py new file mode 100644 index 0000000000000..405adc94b272d --- /dev/null +++ b/homeassistant/components/apsystems/switch.py @@ -0,0 +1,56 @@ +"""The power switch which can be toggled via the APsystems local API integration.""" + +from __future__ import annotations + +from typing import Any + +from aiohttp.client_exceptions import ClientConnectionError +from APsystemsEZ1 import Status + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ApSystemsConfigEntry, ApSystemsData +from .entity import ApSystemsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ApSystemsConfigEntry, + add_entities: AddEntitiesCallback, +) -> None: + """Set up the switch platform.""" + + add_entities([ApSystemsInverterSwitch(config_entry.runtime_data)], True) + + +class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity): + """The switch class for APSystems switches.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_translation_key = "inverter_status" + + def __init__(self, data: ApSystemsData) -> None: + """Initialize the switch.""" + super().__init__(data) + self._api = data.coordinator.api + self._attr_unique_id = f"{data.device_id}_inverter_status" + + async def async_update(self) -> None: + """Update switch status and availability.""" + try: + status = await self._api.get_device_power_status() + except (TimeoutError, ClientConnectionError): + self._attr_available = False + else: + self._attr_available = True + self._attr_is_on = status == Status.normal + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._api.set_device_power_status(0) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._api.set_device_power_status(1) diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py index 682086be38028..c191c7ca2dc08 100644 --- a/tests/components/apsystems/conftest.py +++ b/tests/components/apsystems/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from APsystemsEZ1 import ReturnDeviceInfo, ReturnOutputData +from APsystemsEZ1 import ReturnDeviceInfo, ReturnOutputData, Status import pytest from homeassistant.components.apsystems.const import DOMAIN @@ -52,6 +52,7 @@ def mock_apsystems() -> Generator[MagicMock]: e2=6.0, te2=7.0, ) + mock_api.get_device_power_status.return_value = Status.normal yield mock_api diff --git a/tests/components/apsystems/snapshots/test_switch.ambr b/tests/components/apsystems/snapshots/test_switch.ambr new file mode 100644 index 0000000000000..6daa9fd6e14d7 --- /dev/null +++ b/tests/components/apsystems/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_all_entities[switch.mock_title_inverter_status-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_inverter_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inverter status', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'inverter_status', + 'unique_id': 'MY_SERIAL_NUMBER_inverter_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.mock_title_inverter_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Title Inverter status', + }), + 'context': , + 'entity_id': 'switch.mock_title_inverter_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/apsystems/test_switch.py b/tests/components/apsystems/test_switch.py new file mode 100644 index 0000000000000..afd889fe958ba --- /dev/null +++ b/tests/components/apsystems/test_switch.py @@ -0,0 +1,31 @@ +"""Test the APSystem switch module.""" + +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_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.apsystems.PLATFORMS", + [Platform.SWITCH], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) From 50b35ac4bce293d55525adaae9b5df01b9157762 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Tue, 30 Jul 2024 18:14:01 +0200 Subject: [PATCH 36/42] Add number platform to IronOS integration (#122801) * Add setpoint temperature number entity to IronOS integration * Add tests for number platform * Initialize settings in coordinator * Remove unused code --- homeassistant/components/iron_os/__init__.py | 2 +- homeassistant/components/iron_os/const.py | 3 + homeassistant/components/iron_os/icons.json | 8 +- homeassistant/components/iron_os/number.py | 96 ++++++++++++++++ homeassistant/components/iron_os/strings.json | 8 ++ .../iron_os/snapshots/test_number.ambr | 58 ++++++++++ tests/components/iron_os/test_number.py | 104 ++++++++++++++++++ 7 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/iron_os/number.py create mode 100644 tests/components/iron_os/snapshots/test_number.ambr create mode 100644 tests/components/iron_os/test_number.py diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index bf3c6c34c83ee..11d99a1558a9a 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -16,7 +16,7 @@ from .const import DOMAIN from .coordinator import IronOSCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] type IronOSConfigEntry = ConfigEntry[IronOSCoordinator] diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py index 86b7d401f4fce..34889636808dc 100644 --- a/homeassistant/components/iron_os/const.py +++ b/homeassistant/components/iron_os/const.py @@ -8,3 +8,6 @@ OHM = "Ω" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" + +MAX_TEMP: int = 450 +MIN_TEMP: int = 10 diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 0d207607a4f52..fa14b8134d0a3 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -1,12 +1,14 @@ { "entity": { + "number": { + "setpoint_temperature": { + "default": "mdi:thermometer" + } + }, "sensor": { "live_temperature": { "default": "mdi:soldering-iron" }, - "setpoint_temperature": { - "default": "mdi:thermostat" - }, "voltage": { "default": "mdi:current-dc" }, diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py new file mode 100644 index 0000000000000..9230faec1f13f --- /dev/null +++ b/homeassistant/components/iron_os/number.py @@ -0,0 +1,96 @@ +"""Number platform for IronOS integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pynecil import CharSetting, CommunicationError, LiveDataResponse + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IronOSConfigEntry +from .const import DOMAIN, MAX_TEMP, MIN_TEMP +from .entity import IronOSBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IronOSNumberEntityDescription(NumberEntityDescription): + """Describes IronOS number entity.""" + + value_fn: Callable[[LiveDataResponse], float | int | None] + max_value_fn: Callable[[LiveDataResponse], float | int] + set_key: CharSetting + + +class PinecilNumber(StrEnum): + """Number controls for Pinecil device.""" + + SETPOINT_TEMP = "setpoint_temperature" + + +PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.SETPOINT_TEMP, + translation_key=PinecilNumber.SETPOINT_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + value_fn=lambda data: data.setpoint_temp, + set_key=CharSetting.SETPOINT_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_step=5, + max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IronOSConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + IronOSNumberEntity(coordinator, description) + for description in PINECIL_NUMBER_DESCRIPTIONS + ) + + +class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): + """Implementation of a IronOS number entity.""" + + entity_description: IronOSNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.coordinator.device.write(self.entity_description.set_key, value) + except CommunicationError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="submit_setting_failed", + ) from e + self.async_write_ha_state() + + @property + def native_value(self) -> float | int | None: + """Return sensor state.""" + return self.entity_description.value_fn(self.coordinator.data) + + @property + def native_max_value(self) -> float: + """Return sensor state.""" + return self.entity_description.max_value_fn(self.coordinator.data) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index cb95330b7686a..75584fe191c37 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "number": { + "setpoint_temperature": { + "name": "Setpoint temperature" + } + }, "sensor": { "live_temperature": { "name": "Tip temperature" @@ -79,6 +84,9 @@ }, "setup_device_connection_error_exception": { "message": "Connection to device {name} failed, try again later" + }, + "submit_setting_failed": { + "message": "Failed to submit setting to device, try again later" } } } diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr new file mode 100644 index 0000000000000..2f5ee62e37e1b --- /dev/null +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_state[number.pinecil_setpoint_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 450, + 'min': 10, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.pinecil_setpoint_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': 'Setpoint temperature', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_setpoint_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_setpoint_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pinecil Setpoint temperature', + 'max': 450, + 'min': 10, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_setpoint_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py new file mode 100644 index 0000000000000..c091040668c30 --- /dev/null +++ b/tests/components/iron_os/test_number.py @@ -0,0 +1,104 @@ +"""Tests for the IronOS number platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from pynecil import CharSetting, CommunicationError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.config_entries import ConfigEntryState +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 + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def sensor_only() -> AsyncGenerator[None, None]: + """Enable only the number platform.""" + with patch( + "homeassistant.components.iron_os.PLATFORMS", + [Platform.NUMBER], + ): + yield + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the IronOS number platform states.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_set_value( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the IronOS number platform set value service.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 300}, + target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(CharSetting.SETPOINT_TEMP, 300) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_set_value_exception( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the IronOS number platform set value service with exception.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pynecil.write.side_effect = CommunicationError + + with pytest.raises( + ServiceValidationError, + match="Failed to submit setting to device, try again later", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 300}, + target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, + blocking=True, + ) From fb229fcae85ff6719da7e03f6b8857de83a0110d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 18:40:36 +0200 Subject: [PATCH 37/42] Improve test coverage of the homeworks integration (#122865) * Improve test coverage of the homeworks integration * Revert changes from the future * Revert changes from the future --- .../components/homeworks/__init__.py | 14 +++++------ tests/components/homeworks/test_init.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index e30778f7f15d4..f1a95102c3bf4 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -171,16 +171,14 @@ def cleanup(event: Event) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data: HomeworksData = hass.data[DOMAIN].pop(entry.entry_id) + for keypad in data.keypads.values(): + keypad.unsubscribe() - data: HomeworksData = hass.data[DOMAIN].pop(entry.entry_id) - for keypad in data.keypads.values(): - keypad.unsubscribe() + await hass.async_add_executor_job(data.controller.close) - await hass.async_add_executor_job(data.controller.close) - - return True + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/tests/components/homeworks/test_init.py b/tests/components/homeworks/test_init.py index 87aabb6258fb5..af43fcfba10e9 100644 --- a/tests/components/homeworks/test_init.py +++ b/tests/components/homeworks/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.homeworks import EVENT_BUTTON_PRESS, EVENT_BUTTON_RELEASE from homeassistant.components.homeworks.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -165,3 +166,25 @@ async def test_send_command( blocking=True, ) assert len(mock_controller._send.mock_calls) == 0 + + +async def test_cleanup_on_ha_shutdown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homeworks: MagicMock, +) -> None: + """Test cleanup when HA shuts down.""" + mock_controller = MagicMock() + mock_homeworks.return_value = mock_controller + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY) + mock_controller.stop.assert_not_called() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + mock_controller.close.assert_called_once_with() From 5eff4f981641779972999fb040a3f5854d50ec16 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 30 Jul 2024 19:33:25 +0200 Subject: [PATCH 38/42] Unifi improve fixture typing (#122864) * Improve typing of UniFi fixtures * Improve fixture typing, excluding image, sensor, switch * Improve fixture typing in image tests * Improve fixtures typing in sensor tests * Improve fixture typing in switch tests * Fix review comment --- tests/components/unifi/conftest.py | 37 +++++++--- tests/components/unifi/test_button.py | 26 ++++--- tests/components/unifi/test_config_flow.py | 13 ++-- tests/components/unifi/test_device_tracker.py | 43 +++++++----- tests/components/unifi/test_diagnostics.py | 4 +- tests/components/unifi/test_hub.py | 29 ++++---- tests/components/unifi/test_image.py | 18 +++-- tests/components/unifi/test_init.py | 19 +++--- tests/components/unifi/test_sensor.py | 61 +++++++++-------- tests/components/unifi/test_services.py | 16 ++--- tests/components/unifi/test_switch.py | 67 +++++++++++-------- tests/components/unifi/test_update.py | 21 ++++-- 12 files changed, 214 insertions(+), 140 deletions(-) diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 4e460bab8f823..798b613b18dc5 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Generator +from collections.abc import Callable, Coroutine, Generator from datetime import timedelta from types import MappingProxyType -from typing import Any +from typing import Any, Protocol from unittest.mock import AsyncMock, patch from aiounifi.models.message import MessageKey @@ -16,7 +16,6 @@ from homeassistant.components.unifi import STORAGE_KEY, STORAGE_VERSION from homeassistant.components.unifi.const import CONF_SITE_ID, DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.hub.websocket import RETRY_TIMER -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -52,6 +51,20 @@ "uptime": 1562600160, } +type ConfigEntryFactoryType = Callable[[], Coroutine[Any, Any, MockConfigEntry]] + + +class WebsocketMessageMock(Protocol): + """Fixture to mock websocket message.""" + + def __call__( + self, + *, + message: MessageKey | None = None, + data: list[dict[str, Any]] | dict[str, Any] | None = None, + ) -> None: + """Send websocket message.""" + @pytest.fixture(autouse=True, name="mock_discovery") def fixture_discovery(): @@ -96,7 +109,7 @@ def fixture_config_entry( hass: HomeAssistant, config_entry_data: MappingProxyType[str, Any], config_entry_options: MappingProxyType[str, Any], -) -> ConfigEntry: +) -> MockConfigEntry: """Define a config entry fixture.""" config_entry = MockConfigEntry( domain=UNIFI_DOMAIN, @@ -295,12 +308,12 @@ def fixture_default_requests( @pytest.fixture(name="config_entry_factory") async def fixture_config_entry_factory( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MockConfigEntry, mock_requests: Callable[[str, str], None], -) -> Callable[[], ConfigEntry]: +) -> ConfigEntryFactoryType: """Fixture factory that can set up UniFi network integration.""" - async def __mock_setup_config_entry() -> ConfigEntry: + async def __mock_setup_config_entry() -> MockConfigEntry: mock_requests(config_entry.data[CONF_HOST], config_entry.data[CONF_SITE_ID]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -311,8 +324,8 @@ async def __mock_setup_config_entry() -> ConfigEntry: @pytest.fixture(name="config_entry_setup") async def fixture_config_entry_setup( - config_entry_factory: Callable[[], ConfigEntry], -) -> ConfigEntry: + config_entry_factory: ConfigEntryFactoryType, +) -> MockConfigEntry: """Fixture providing a set up instance of UniFi network integration.""" return await config_entry_factory() @@ -382,13 +395,15 @@ def fixture_aiounifi_websocket_state( @pytest.fixture(name="mock_websocket_message") -def fixture_aiounifi_websocket_message(_mock_websocket: AsyncMock): +def fixture_aiounifi_websocket_message( + _mock_websocket: AsyncMock, +) -> WebsocketMessageMock: """No real websocket allowed.""" def make_websocket_call( *, message: MessageKey | None = None, - data: list[dict] | dict | None = None, + data: list[dict[str, Any]] | dict[str, Any] | None = None, ) -> None: """Generate a websocket call.""" message_handler = _mock_websocket.call_args[0][0] diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 9af96b64a5025..fc3aeccea9fcd 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.unifi.const import CONF_SITE_ID -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( CONF_HOST, CONTENT_TYPE_JSON, @@ -23,7 +23,13 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, snapshot_platform +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.test_util.aiohttp import AiohttpClientMocker RANDOM_TOKEN = "random_token" @@ -134,7 +140,7 @@ def mock_secret(): async def test_entity_and_device_data( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory, + config_entry_factory: ConfigEntryFactoryType, site_payload: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: @@ -150,8 +156,8 @@ async def test_entity_and_device_data( async def _test_button_entity( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_websocket_state, - config_entry: ConfigEntry, + mock_websocket_state: WebsocketStateManager, + config_entry: MockConfigEntry, entity_id: str, request_method: str, request_path: str, @@ -221,8 +227,8 @@ async def _test_button_entity( async def test_device_button_entities( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, - mock_websocket_state, + config_entry_setup: MockConfigEntry, + mock_websocket_state: WebsocketStateManager, entity_id: str, request_method: str, request_path: str, @@ -269,8 +275,8 @@ async def test_wlan_button_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, - mock_websocket_state, + config_entry_setup: MockConfigEntry, + mock_websocket_state: WebsocketStateManager, entity_id: str, request_method: str, request_path: str, @@ -308,7 +314,7 @@ async def test_wlan_button_entities( @pytest.mark.usefixtures("config_entry_setup") async def test_power_cycle_availability( hass: HomeAssistant, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: dict[str, Any], ) -> None: """Verify that disabling PoE marks entity as unavailable.""" diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index fc0d2626eb684..1d745511dc59c 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -1,6 +1,5 @@ """Test UniFi Network config flow.""" -from collections.abc import Callable import socket from unittest.mock import PropertyMock, patch @@ -25,7 +24,7 @@ CONF_TRACK_WIRED_CLIENTS, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -36,6 +35,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import ConfigEntryFactoryType + from tests.common import MockConfigEntry CLIENTS = [{"mac": "00:00:00:00:00:01"}] @@ -296,7 +297,7 @@ async def test_flow_fails_hub_unavailable(hass: HomeAssistant) -> None: async def test_reauth_flow_update_configuration( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Verify reauth flow can update hub configuration.""" config_entry = config_entry_setup @@ -337,7 +338,7 @@ async def test_reauth_flow_update_configuration( async def test_reauth_flow_update_configuration_on_not_loaded_entry( - hass: HomeAssistant, config_entry_factory: Callable[[], ConfigEntry] + hass: HomeAssistant, config_entry_factory: ConfigEntryFactoryType ) -> None: """Verify reauth flow can update hub configuration on a not loaded entry.""" with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.RequestError): @@ -379,7 +380,7 @@ async def test_reauth_flow_update_configuration_on_not_loaded_entry( @pytest.mark.parametrize("wlan_payload", [WLANS]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) async def test_advanced_option_flow( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Test advanced config flow options.""" config_entry = config_entry_setup @@ -463,7 +464,7 @@ async def test_advanced_option_flow( @pytest.mark.parametrize("client_payload", [CLIENTS]) async def test_simple_option_flow( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Test simple config flow options.""" config_entry = config_entry_setup diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index f2480a4f050a7..c653370656dc1 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,6 +1,5 @@ """The tests for the UniFi Network device tracker platform.""" -from collections.abc import Callable from datetime import timedelta from types import MappingProxyType from typing import Any @@ -24,13 +23,18 @@ DEFAULT_DETECTION_TIME, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, snapshot_platform +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform WIRED_CLIENT_1 = { "hostname": "wd_client_1", @@ -96,7 +100,7 @@ async def test_entity_and_device_data( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory, + config_entry_factory: ConfigEntryFactoryType, snapshot: SnapshotAssertion, ) -> None: """Validate entity and device data with and without admin rights.""" @@ -112,8 +116,8 @@ async def test_entity_and_device_data( @pytest.mark.usefixtures("mock_device_registry") async def test_client_state_update( hass: HomeAssistant, - mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + mock_websocket_message: WebsocketMessageMock, + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], ) -> None: """Verify tracking of wireless clients.""" @@ -165,7 +169,7 @@ async def test_client_state_update( async def test_client_state_from_event_source( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, client_payload: list[dict[str, Any]], ) -> None: """Verify update state of client based on event source.""" @@ -247,8 +251,8 @@ async def mock_event(client: dict[str, Any], event_key: EventKey) -> dict[str, A async def test_tracked_device_state_change( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - config_entry_factory: Callable[[], ConfigEntry], - mock_websocket_message, + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], state: int, interval: int, @@ -289,7 +293,9 @@ async def test_tracked_device_state_change( @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("mock_device_registry") async def test_remove_clients( - hass: HomeAssistant, mock_websocket_message, client_payload: list[dict[str, Any]] + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, + client_payload: list[dict[str, Any]], ) -> None: """Test the remove_items function with some clients.""" assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 @@ -309,7 +315,10 @@ async def test_remove_clients( @pytest.mark.parametrize("device_payload", [[SWITCH_1]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("mock_device_registry") -async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: +async def test_hub_state_change( + hass: HomeAssistant, + mock_websocket_state: WebsocketStateManager, +) -> None: """Verify entities state reflect on hub connection becoming unavailable.""" assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME @@ -330,7 +339,7 @@ async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> No async def test_option_ssid_filter( hass: HomeAssistant, mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], ) -> None: """Test the SSID filter works. @@ -434,7 +443,7 @@ async def test_option_ssid_filter( async def test_wireless_client_go_wired_issue( hass: HomeAssistant, mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], ) -> None: """Test the solution to catch wireless device go wired UniFi issue. @@ -494,7 +503,7 @@ async def test_wireless_client_go_wired_issue( async def test_option_ignore_wired_bug( hass: HomeAssistant, mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], ) -> None: """Test option to ignore wired bug.""" @@ -571,8 +580,8 @@ async def test_option_ignore_wired_bug( async def test_restoring_client( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry: ConfigEntry, - config_entry_factory: Callable[[], ConfigEntry], + config_entry: MockConfigEntry, + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], clients_all_payload: list[dict[str, Any]], ) -> None: @@ -645,7 +654,7 @@ async def test_restoring_client( @pytest.mark.usefixtures("mock_device_registry") async def test_config_entry_options_track( hass: HomeAssistant, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, config_entry_options: MappingProxyType[str, Any], counts: tuple[int], expected: tuple[tuple[bool | None, ...], ...], diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 3963de2deb38f..80359a9c75cd5 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -9,9 +9,9 @@ CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, ) -from homeassistant.config_entries import ConfigEntry 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 @@ -122,7 +122,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 0d75a83c5f5ff..af134c7449b67 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -1,6 +1,5 @@ """Test UniFi Network.""" -from collections.abc import Callable from http import HTTPStatus from types import MappingProxyType from typing import Any @@ -12,18 +11,21 @@ from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.components.unifi.hub import get_unifi_api -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util +from .conftest import ConfigEntryFactoryType, WebsocketStateManager + +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker async def test_hub_setup( device_registry: dr.DeviceRegistry, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, ) -> None: """Successful setup.""" with patch( @@ -54,7 +56,7 @@ async def test_hub_setup( async def test_reset_after_successful_setup( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Calling reset when the entry has been setup.""" assert config_entry_setup.state is ConfigEntryState.LOADED @@ -64,7 +66,7 @@ async def test_reset_after_successful_setup( async def test_reset_fails( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Calling reset when the entry has been setup can return false.""" assert config_entry_setup.state is ConfigEntryState.LOADED @@ -80,8 +82,8 @@ async def test_reset_fails( @pytest.mark.usefixtures("mock_device_registry") async def test_connection_state_signalling( hass: HomeAssistant, - mock_websocket_state, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_state: WebsocketStateManager, client_payload: list[dict[str, Any]], ) -> None: """Verify connection statesignalling and connection state are working.""" @@ -110,8 +112,8 @@ async def test_connection_state_signalling( async def test_reconnect_mechanism( aioclient_mock: AiohttpClientMocker, - mock_websocket_state, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, + mock_websocket_state: WebsocketStateManager, ) -> None: """Verify reconnect prints only on first reconnection try.""" aioclient_mock.clear_requests() @@ -140,7 +142,10 @@ async def test_reconnect_mechanism( ], ) @pytest.mark.usefixtures("config_entry_setup") -async def test_reconnect_mechanism_exceptions(mock_websocket_state, exception) -> None: +async def test_reconnect_mechanism_exceptions( + mock_websocket_state: WebsocketStateManager, + exception: Exception, +) -> None: """Verify async_reconnect calls expected methods.""" with ( patch("aiounifi.Controller.login", side_effect=exception), @@ -170,8 +175,8 @@ async def test_reconnect_mechanism_exceptions(mock_websocket_state, exception) - ) async def test_get_unifi_api_fails_to_connect( hass: HomeAssistant, - side_effect, - raised_exception, + side_effect: Exception, + raised_exception: Exception, config_entry_data: MappingProxyType[str, Any], ) -> None: """Check that get_unifi_api can handle UniFi Network being unavailable.""" diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index 6733845c52f2f..dc37d7cb8b73d 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -18,6 +18,12 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + from tests.common import async_fire_time_changed, snapshot_platform from tests.typing import ClientSessionGenerator @@ -82,7 +88,7 @@ def mock_getrandbits(): async def test_entity_and_device_data( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory, + config_entry_factory: ConfigEntryFactoryType, site_payload: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: @@ -102,7 +108,7 @@ async def test_wlan_qr_code( entity_registry: er.EntityRegistry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, ) -> None: """Test the update_clients function when no clients are found.""" assert len(hass.states.async_entity_ids(IMAGE_DOMAIN)) == 0 @@ -151,7 +157,9 @@ async def test_wlan_qr_code( @pytest.mark.parametrize("wlan_payload", [[WLAN]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: +async def test_hub_state_change( + hass: HomeAssistant, mock_websocket_state: WebsocketStateManager +) -> None: """Verify entities state reflect on hub becoming unavailable.""" assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE @@ -167,7 +175,9 @@ async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> No @pytest.mark.parametrize("wlan_payload", [[WLAN]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_source_availability(hass: HomeAssistant, mock_websocket_message) -> None: +async def test_source_availability( + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock +) -> None: """Verify entities state reflect on source becoming unavailable.""" assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index de08ba2c6d7d2..68f80555cd663 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -1,6 +1,5 @@ """Test UniFi Network integration setup process.""" -from collections.abc import Callable from typing import Any from unittest.mock import patch @@ -15,19 +14,23 @@ CONF_TRACK_DEVICES, ) from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .conftest import DEFAULT_CONFIG_ENTRY_ID +from .conftest import ( + DEFAULT_CONFIG_ENTRY_ID, + ConfigEntryFactoryType, + WebsocketMessageMock, +) from tests.common import flush_store from tests.typing import WebSocketGenerator async def test_setup_entry_fails_config_entry_not_ready( - config_entry_factory: Callable[[], ConfigEntry], + hass: HomeAssistant, config_entry_factory: ConfigEntryFactoryType ) -> None: """Failed authentication trigger a reauthentication flow.""" with patch( @@ -40,7 +43,7 @@ async def test_setup_entry_fails_config_entry_not_ready( async def test_setup_entry_fails_trigger_reauth_flow( - hass: HomeAssistant, config_entry_factory: Callable[[], ConfigEntry] + hass: HomeAssistant, config_entry_factory: ConfigEntryFactoryType ) -> None: """Failed authentication trigger a reauthentication flow.""" with ( @@ -78,7 +81,7 @@ async def test_setup_entry_fails_trigger_reauth_flow( async def test_wireless_clients( hass: HomeAssistant, hass_storage: dict[str, Any], - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, ) -> None: """Verify wireless clients class.""" hass_storage[unifi.STORAGE_KEY] = { @@ -163,10 +166,10 @@ async def test_wireless_clients( async def test_remove_config_entry_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, client_payload: list[dict[str, Any]], device_payload: list[dict[str, Any]], - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, hass_ws_client: WebSocketGenerator, ) -> None: """Verify removing a device manually.""" diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 779df6660f063..5af4b29784727 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1,6 +1,5 @@ """UniFi Network sensor platform tests.""" -from collections.abc import Callable from copy import deepcopy from datetime import datetime, timedelta from types import MappingProxyType @@ -29,7 +28,7 @@ DEFAULT_DETECTION_TIME, DEVICE_STATES, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -42,7 +41,13 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, async_fire_time_changed DEVICE_1 = { "board_rev": 2, @@ -362,9 +367,9 @@ async def test_no_clients(hass: HomeAssistant) -> None: ) async def test_bandwidth_sensors( hass: HomeAssistant, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, config_entry_options: MappingProxyType[str, Any], - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, client_payload: list[dict[str, Any]], ) -> None: """Verify that bandwidth sensors are working as expected.""" @@ -491,7 +496,9 @@ async def test_bandwidth_sensors( @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_remove_sensors( - hass: HomeAssistant, mock_websocket_message, client_payload: list[dict[str, Any]] + hass: HomeAssistant, + mock_websocket_message: WebsocketMessageMock, + client_payload: list[dict[str, Any]], ) -> None: """Verify removing of clients work as expected.""" assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 @@ -520,8 +527,8 @@ async def test_remove_sensors( async def test_poe_port_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, - mock_websocket_state, + mock_websocket_message: WebsocketMessageMock, + mock_websocket_state: WebsocketStateManager, ) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 @@ -594,9 +601,9 @@ async def test_poe_port_switches( async def test_wlan_client_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, - mock_websocket_state, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_message: WebsocketMessageMock, + mock_websocket_state: WebsocketStateManager, client_payload: list[dict[str, Any]], ) -> None: """Verify that WLAN client sensors are working as expected.""" @@ -736,7 +743,7 @@ async def test_wlan_client_sensors( async def test_outlet_power_readings( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], entity_id: str, expected_unique_id: str, @@ -798,7 +805,7 @@ async def test_outlet_power_readings( async def test_device_temperature( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], ) -> None: """Verify that temperature sensors are working as expected.""" @@ -847,7 +854,7 @@ async def test_device_temperature( async def test_device_state( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], ) -> None: """Verify that state sensors are working as expected.""" @@ -884,7 +891,7 @@ async def test_device_state( async def test_device_system_stats( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], ) -> None: """Verify that device stats sensors are working as expected.""" @@ -979,9 +986,9 @@ async def test_device_system_stats( async def test_bandwidth_port_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, config_entry_options: MappingProxyType[str, Any], + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], ) -> None: """Verify that port bandwidth sensors are working as expected.""" @@ -1096,9 +1103,9 @@ async def test_bandwidth_port_sensors( async def test_device_client_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory, - mock_websocket_message, - client_payload, + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_message: WebsocketMessageMock, + client_payload: dict[str, Any], ) -> None: """Verify that WLAN client sensors are working as expected.""" client_payload += [ @@ -1246,8 +1253,8 @@ async def test_sensor_sources( async def _test_uptime_entity( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + mock_websocket_message: WebsocketMessageMock, + config_entry_factory: ConfigEntryFactoryType, payload: dict[str, Any], entity_id: str, message_key: MessageKey, @@ -1326,9 +1333,9 @@ async def test_client_uptime( hass: HomeAssistant, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, - mock_websocket_message, config_entry_options: MappingProxyType[str, Any], - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_message: WebsocketMessageMock, client_payload: list[dict[str, Any]], initial_uptime, event_uptime, @@ -1401,8 +1408,8 @@ async def test_device_uptime( hass: HomeAssistant, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, - mock_websocket_message, - config_entry_factory: Callable[[], ConfigEntry], + config_entry_factory: ConfigEntryFactoryType, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], ) -> None: """Verify that device uptime sensors are working as expected.""" @@ -1502,7 +1509,7 @@ async def test_device_uptime( async def test_wan_monitor_latency( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_websocket_message, + mock_websocket_message: WebsocketMessageMock, device_payload: list[dict[str, Any]], entity_id: str, state: str, diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py index bf7058e28fff1..a7968a92e22fd 100644 --- a/tests/components/unifi/test_services.py +++ b/tests/components/unifi/test_services.py @@ -10,11 +10,11 @@ SERVICE_RECONNECT_CLIENT, SERVICE_REMOVE_CLIENTS, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,7 +25,7 @@ async def test_reconnect_client( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, client_payload: list[dict[str, Any]], ) -> None: """Verify call to reconnect client is performed as expected.""" @@ -69,7 +69,7 @@ async def test_reconnect_device_without_mac( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, ) -> None: """Verify no call is made if device does not have a known mac.""" aioclient_mock.clear_requests() @@ -95,7 +95,7 @@ async def test_reconnect_client_hub_unavailable( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, client_payload: list[dict[str, Any]], ) -> None: """Verify no call is made if hub is unavailable.""" @@ -127,7 +127,7 @@ async def test_reconnect_client_unknown_mac( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, ) -> None: """Verify no call is made if trying to reconnect a mac unknown to hub.""" aioclient_mock.clear_requests() @@ -152,7 +152,7 @@ async def test_reconnect_wired_client( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, client_payload: list[dict[str, Any]], ) -> None: """Verify no call is made if client is wired.""" @@ -204,7 +204,7 @@ async def test_reconnect_wired_client( async def test_remove_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, ) -> None: """Verify removing different variations of clients work.""" aioclient_mock.clear_requests() @@ -288,7 +288,7 @@ async def test_services_handle_unloaded_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, clients_all_payload: dict[str, Any], ) -> None: """Verify no call is made if config entry is unloaded.""" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index daf64301c8ead..6d85437a24438 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,6 +1,5 @@ """UniFi Network switch platform tests.""" -from collections.abc import Callable from copy import deepcopy from datetime import timedelta from typing import Any @@ -22,7 +21,7 @@ CONF_TRACK_DEVICES, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -37,9 +36,14 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .conftest import CONTROLLER_HOST +from .conftest import ( + CONTROLLER_HOST, + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_1 = { @@ -841,12 +845,11 @@ async def test_not_admin(hass: HomeAssistant) -> None: @pytest.mark.parametrize("clients_all_payload", [[BLOCKED, UNBLOCKED, CLIENT_1]]) @pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) -@pytest.mark.usefixtures("config_entry_setup") async def test_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, ) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 3 @@ -930,7 +933,9 @@ async def test_switches( @pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) @pytest.mark.usefixtures("config_entry_setup") -async def test_remove_switches(hass: HomeAssistant, mock_websocket_message) -> None: +async def test_remove_switches( + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock +) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -967,8 +972,8 @@ async def test_remove_switches(hass: HomeAssistant, mock_websocket_message) -> N async def test_block_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + mock_websocket_message: WebsocketMessageMock, + config_entry_setup: MockConfigEntry, ) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -1027,7 +1032,9 @@ async def test_block_switches( @pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) @pytest.mark.usefixtures("config_entry_setup") -async def test_dpi_switches(hass: HomeAssistant, mock_websocket_message) -> None: +async def test_dpi_switches( + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock +) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -1053,7 +1060,7 @@ async def test_dpi_switches(hass: HomeAssistant, mock_websocket_message) -> None @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) @pytest.mark.usefixtures("config_entry_setup") async def test_dpi_switches_add_second_app( - hass: HomeAssistant, mock_websocket_message + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock ) -> None: """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -1104,12 +1111,10 @@ async def test_dpi_switches_add_second_app( @pytest.mark.parametrize(("traffic_rule_payload"), [([TRAFFIC_RULE])]) -@pytest.mark.usefixtures("config_entry_setup") async def test_traffic_rules( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, traffic_rule_payload: list[dict[str, Any]], ) -> None: """Test control of UniFi traffic rules.""" @@ -1172,8 +1177,8 @@ async def test_traffic_rules( async def test_outlet_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + mock_websocket_message: WebsocketMessageMock, + config_entry_setup: MockConfigEntry, device_payload: list[dict[str, Any]], entity_id: str, outlet_index: int, @@ -1268,7 +1273,7 @@ async def test_outlet_switches( ) @pytest.mark.usefixtures("config_entry_setup") async def test_new_client_discovered_on_block_control( - hass: HomeAssistant, mock_websocket_message + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock ) -> None: """Test if 2nd update has a new client.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -1286,7 +1291,9 @@ async def test_new_client_discovered_on_block_control( ) @pytest.mark.parametrize("clients_all_payload", [[BLOCKED, UNBLOCKED]]) async def test_option_block_clients( - hass: HomeAssistant, config_entry_setup: ConfigEntry, clients_all_payload + hass: HomeAssistant, + config_entry_setup: MockConfigEntry, + clients_all_payload: list[dict[str, Any]], ) -> None: """Test the changes to option reflects accordingly.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -1334,7 +1341,7 @@ async def test_option_block_clients( @pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) async def test_option_remove_switches( - hass: HomeAssistant, config_entry_setup: ConfigEntry + hass: HomeAssistant, config_entry_setup: MockConfigEntry ) -> None: """Test removal of DPI switch when options updated.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -1352,8 +1359,8 @@ async def test_poe_port_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + mock_websocket_message: WebsocketMessageMock, + config_entry_setup: MockConfigEntry, device_payload: list[dict[str, Any]], ) -> None: """Test PoE port entities work.""" @@ -1451,8 +1458,8 @@ async def test_wlan_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + mock_websocket_message: WebsocketMessageMock, + config_entry_setup: MockConfigEntry, wlan_payload: list[dict[str, Any]], ) -> None: """Test control of UniFi WLAN availability.""" @@ -1507,8 +1514,8 @@ async def test_port_forwarding_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_websocket_message, - config_entry_setup: ConfigEntry, + mock_websocket_message: WebsocketMessageMock, + config_entry_setup: MockConfigEntry, port_forward_payload: list[dict[str, Any]], ) -> None: """Test control of UniFi port forwarding.""" @@ -1606,9 +1613,9 @@ async def test_port_forwarding_switches( async def test_updating_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory: Callable[[], ConfigEntry], - config_entry: ConfigEntry, - device_payload, + config_entry_factory: ConfigEntryFactoryType, + config_entry: MockConfigEntry, + device_payload: list[dict[str, Any]], ) -> None: """Verify outlet control and poe control unique ID update works.""" entity_registry.async_get_or_create( @@ -1644,7 +1651,9 @@ async def test_updating_unique_id( @pytest.mark.parametrize("wlan_payload", [[WLAN]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: +async def test_hub_state_change( + hass: HomeAssistant, mock_websocket_state: WebsocketStateManager +) -> None: """Verify entities state reflect on hub connection becoming unavailable.""" entity_ids = ( "switch.block_client_2", diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index a8fe9231159c7..7bf4b9aec9d03 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -16,7 +16,6 @@ DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -28,7 +27,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import snapshot_platform +from .conftest import ( + ConfigEntryFactoryType, + WebsocketMessageMock, + WebsocketStateManager, +) + +from tests.common import MockConfigEntry, snapshot_platform from tests.test_util.aiohttp import AiohttpClientMocker # Device with new firmware available @@ -74,7 +79,7 @@ async def test_entity_and_device_data( hass: HomeAssistant, entity_registry: er.EntityRegistry, - config_entry_factory, + config_entry_factory: ConfigEntryFactoryType, snapshot: SnapshotAssertion, ) -> None: """Validate entity and device data with and without admin rights.""" @@ -85,7 +90,9 @@ async def test_entity_and_device_data( @pytest.mark.parametrize("device_payload", [[DEVICE_1]]) @pytest.mark.usefixtures("config_entry_setup") -async def test_device_updates(hass: HomeAssistant, mock_websocket_message) -> None: +async def test_device_updates( + hass: HomeAssistant, mock_websocket_message: WebsocketMessageMock +) -> None: """Test the update_items function with some devices.""" device_1_state = hass.states.get("update.device_1") assert device_1_state.state == STATE_ON @@ -122,7 +129,7 @@ async def test_device_updates(hass: HomeAssistant, mock_websocket_message) -> No async def test_install( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - config_entry_setup: ConfigEntry, + config_entry_setup: MockConfigEntry, ) -> None: """Test the device update install call.""" device_state = hass.states.get("update.device_1") @@ -154,7 +161,9 @@ async def test_install( @pytest.mark.parametrize("device_payload", [[DEVICE_1]]) @pytest.mark.usefixtures("config_entry_setup") -async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: +async def test_hub_state_change( + hass: HomeAssistant, mock_websocket_state: WebsocketStateManager +) -> None: """Verify entities state reflect on hub becoming unavailable.""" assert hass.states.get("update.device_1").state == STATE_ON From 94c0b9fc069b02bab31dff04e71ae0457e03f336 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 19:39:53 +0200 Subject: [PATCH 39/42] Bump pyhomeworks to 1.0.0 (#122867) --- homeassistant/components/homeworks/__init__.py | 14 ++++++++------ homeassistant/components/homeworks/config_flow.py | 7 ++++--- homeassistant/components/homeworks/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homeworks/test_config_flow.py | 8 ++++++-- tests/components/homeworks/test_init.py | 7 +++++-- 7 files changed, 26 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index f1a95102c3bf4..cf39bc72ec61b 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -8,6 +8,7 @@ import logging from typing import Any +from pyhomeworks import exceptions as hw_exceptions from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks import voluptuous as vol @@ -141,15 +142,16 @@ def hw_callback(msg_type: Any, values: Any) -> None: dispatcher_send(hass, signal, msg_type, values) config = entry.options + controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback) try: - controller = await hass.async_add_executor_job( - Homeworks, config[CONF_HOST], config[CONF_PORT], hw_callback - ) - except (ConnectionError, OSError) as err: + await hass.async_add_executor_job(controller.connect) + except hw_exceptions.HomeworksException as err: + _LOGGER.debug("Failed to connect: %s", err, exc_info=True) raise ConfigEntryNotReady from err + controller.start() def cleanup(event: Event) -> None: - controller.close() + controller.stop() entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) @@ -176,7 +178,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for keypad in data.keypads.values(): keypad.unsubscribe() - await hass.async_add_executor_job(data.controller.close) + await hass.async_add_executor_job(data.controller.stop) return unload_ok diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index 81b31e4644e0d..ec381c3331f98 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any +from pyhomeworks import exceptions as hw_exceptions from pyhomeworks.pyhomeworks import Homeworks import voluptuous as vol @@ -128,18 +129,18 @@ def _try_connect(host: str, port: int) -> None: "Trying to connect to %s:%s", user_input[CONF_HOST], user_input[CONF_PORT] ) controller = Homeworks(host, port, lambda msg_types, values: None) + controller.connect() controller.close() - controller.join() hass = async_get_hass() try: await hass.async_add_executor_job( _try_connect, user_input[CONF_HOST], user_input[CONF_PORT] ) - except ConnectionError as err: + except hw_exceptions.HomeworksConnectionFailed as err: raise SchemaFlowError("connection_error") from err except Exception as err: - _LOGGER.exception("Caught unexpected exception") + _LOGGER.exception("Caught unexpected exception %s") raise SchemaFlowError("unknown_error") from err diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json index c2520b910d9fb..9b447ef4aea60 100644 --- a/homeassistant/components/homeworks/manifest.json +++ b/homeassistant/components/homeworks/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homeworks", "iot_class": "local_push", "loggers": ["pyhomeworks"], - "requirements": ["pyhomeworks==0.0.6"] + "requirements": ["pyhomeworks==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d74e059a5927..e0d502f30007b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1903,7 +1903,7 @@ pyhiveapi==0.5.16 pyhomematic==0.1.77 # homeassistant.components.homeworks -pyhomeworks==0.0.6 +pyhomeworks==1.0.0 # homeassistant.components.ialarm pyialarm==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86fdf2da7f98d..a1037d5109fe9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1517,7 +1517,7 @@ pyhiveapi==0.5.16 pyhomematic==0.1.77 # homeassistant.components.homeworks -pyhomeworks==0.0.6 +pyhomeworks==1.0.0 # homeassistant.components.ialarm pyialarm==2.2.0 diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index 8f5334b21f993..3e359caf7f2d9 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import ANY, MagicMock +from pyhomeworks import exceptions as hw_exceptions import pytest from pytest_unordered import unordered @@ -55,7 +56,7 @@ async def test_user_flow( } mock_homeworks.assert_called_once_with("192.168.0.1", 1234, ANY) mock_controller.close.assert_called_once_with() - mock_controller.join.assert_called_once_with() + mock_controller.join.assert_not_called() async def test_user_flow_already_exists( @@ -96,7 +97,10 @@ async def test_user_flow_already_exists( @pytest.mark.parametrize( ("side_effect", "error"), - [(ConnectionError, "connection_error"), (Exception, "unknown_error")], + [ + (hw_exceptions.HomeworksConnectionFailed, "connection_error"), + (Exception, "unknown_error"), + ], ) async def test_user_flow_cannot_connect( hass: HomeAssistant, diff --git a/tests/components/homeworks/test_init.py b/tests/components/homeworks/test_init.py index af43fcfba10e9..2363e0f157dab 100644 --- a/tests/components/homeworks/test_init.py +++ b/tests/components/homeworks/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import ANY, MagicMock +from pyhomeworks import exceptions as hw_exceptions from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED import pytest @@ -41,7 +42,9 @@ async def test_config_entry_not_ready( mock_homeworks: MagicMock, ) -> None: """Test the Homeworks configuration entry not ready.""" - mock_homeworks.side_effect = ConnectionError + mock_homeworks.return_value.connect.side_effect = ( + hw_exceptions.HomeworksConnectionFailed + ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -187,4 +190,4 @@ async def test_cleanup_on_ha_shutdown( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - mock_controller.close.assert_called_once_with() + mock_controller.stop.assert_called_once_with() From 022e1b0c02a60c90628f2d4ad11edb0fad445fb4 Mon Sep 17 00:00:00 2001 From: Bill Flood Date: Tue, 30 Jul 2024 12:07:12 -0700 Subject: [PATCH 40/42] Add other medium types to Mopeka sensor (#122705) Co-authored-by: J. Nick Koston --- homeassistant/components/mopeka/__init__.py | 15 +++- .../components/mopeka/config_flow.py | 86 +++++++++++++++++-- homeassistant/components/mopeka/const.py | 8 ++ homeassistant/components/mopeka/strings.json | 18 +++- tests/components/mopeka/test_config_flow.py | 52 +++++++++-- 5 files changed, 163 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mopeka/__init__.py b/homeassistant/components/mopeka/__init__.py index 2538ec3d81048..17a87efd6e6d6 100644 --- a/homeassistant/components/mopeka/__init__.py +++ b/homeassistant/components/mopeka/__init__.py @@ -4,7 +4,7 @@ import logging -from mopeka_iot_ble import MopekaIOTBluetoothDeviceData +from mopeka_iot_ble import MediumType, MopekaIOTBluetoothDeviceData from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( @@ -14,6 +14,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_MEDIUM_TYPE + PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -26,7 +28,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bo """Set up Mopeka BLE device from a config entry.""" address = entry.unique_id assert address is not None - data = MopekaIOTBluetoothDeviceData() + + # Default sensors configured prior to the intorudction of MediumType + medium_type_str = entry.data.get(CONF_MEDIUM_TYPE, MediumType.PROPANE.value) + data = MopekaIOTBluetoothDeviceData(MediumType(medium_type_str)) coordinator = entry.runtime_data = PassiveBluetoothProcessorCoordinator( hass, _LOGGER, @@ -37,9 +42,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # only start after all platforms have had a chance to subscribe entry.async_on_unload(coordinator.async_start()) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True +async def update_listener(hass: HomeAssistant, entry: MopekaConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mopeka/config_flow.py b/homeassistant/components/mopeka/config_flow.py index 1732157ce4961..72e9386a47f54 100644 --- a/homeassistant/components/mopeka/config_flow.py +++ b/homeassistant/components/mopeka/config_flow.py @@ -2,19 +2,43 @@ from __future__ import annotations +from enum import Enum from typing import Any from mopeka_iot_ble import MopekaIOTBluetoothDeviceData as DeviceData import voluptuous as vol +from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback -from .const import DOMAIN +from .const import CONF_MEDIUM_TYPE, DEFAULT_MEDIUM_TYPE, DOMAIN, MediumType + + +def format_medium_type(medium_type: Enum) -> str: + """Format the medium type for human reading.""" + return medium_type.name.replace("_", " ").title() + + +MEDIUM_TYPES_BY_NAME = { + medium.value: format_medium_type(medium) for medium in MediumType +} + + +def async_generate_schema(medium_type: str | None = None) -> vol.Schema: + """Return the base schema with formatted medium types.""" + return vol.Schema( + { + vol.Required( + CONF_MEDIUM_TYPE, default=medium_type or DEFAULT_MEDIUM_TYPE + ): vol.In(MEDIUM_TYPES_BY_NAME) + } + ) class MopekaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -28,6 +52,14 @@ def __init__(self) -> None: self._discovered_device: DeviceData | None = None self._discovered_devices: dict[str, str] = {} + @callback + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MopekaOptionsFlow: + """Return the options flow for this handler.""" + return MopekaOptionsFlow(config_entry) + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: @@ -44,32 +76,39 @@ async def async_step_bluetooth( async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm discovery.""" + """Confirm discovery and select medium type.""" assert self._discovered_device is not None device = self._discovered_device assert self._discovery_info is not None discovery_info = self._discovery_info title = device.title or device.get_device_name() or discovery_info.name if user_input is not None: - return self.async_create_entry(title=title, data={}) + self._discovered_devices[discovery_info.address] = title + return self.async_create_entry( + title=self._discovered_devices[discovery_info.address], + data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, + ) self._set_confirm_only() placeholders = {"name": title} self.context["title_placeholders"] = placeholders return self.async_show_form( - step_id="bluetooth_confirm", description_placeholders=placeholders + step_id="bluetooth_confirm", + description_placeholders=placeholders, + data_schema=async_generate_schema(), ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the user step to pick discovered device.""" + """Handle the user step to pick discovered device and select medium type.""" if user_input is not None: address = user_input[CONF_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=self._discovered_devices[address], data={} + title=self._discovered_devices[address], + data={CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE]}, ) current_addresses = self._async_current_ids() @@ -89,6 +128,39 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + { + vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices), + **async_generate_schema().schema, + } + ), + ) + + +class MopekaOptionsFlow(config_entries.OptionsFlow): + """Handle options for the Mopeka component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + if user_input is not None: + new_data = { + **self.config_entry.data, + CONF_MEDIUM_TYPE: user_input[CONF_MEDIUM_TYPE], + } + self.hass.config_entries.async_update_entry( + self.config_entry, data=new_data + ) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="init", + data_schema=async_generate_schema( + self.config_entry.data.get(CONF_MEDIUM_TYPE) ), ) diff --git a/homeassistant/components/mopeka/const.py b/homeassistant/components/mopeka/const.py index 0d78146f5a885..e18828f2364ef 100644 --- a/homeassistant/components/mopeka/const.py +++ b/homeassistant/components/mopeka/const.py @@ -1,3 +1,11 @@ """Constants for the Mopeka integration.""" +from typing import Final + +from mopeka_iot_ble import MediumType + DOMAIN = "mopeka" + +CONF_MEDIUM_TYPE: Final = "medium_type" + +DEFAULT_MEDIUM_TYPE = MediumType.PROPANE.value diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index 16a80220a2059..2455eea2f7644 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -5,11 +5,15 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:common::config_flow::data::device%]" + "address": "[%key:common::config_flow::data::device%]", + "medium_type": "Medium Type" } }, "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]", + "data": { + "medium_type": "[%key:component::mopeka::config::step::user::data::medium_type%]" + } } }, "abort": { @@ -18,5 +22,15 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "options": { + "step": { + "init": { + "title": "Configure Mopeka", + "data": { + "medium_type": "[%key:component::mopeka::config::step::user::data::medium_type%]" + } + } + } } } diff --git a/tests/components/mopeka/test_config_flow.py b/tests/components/mopeka/test_config_flow.py index 826fe8db2aa2b..7a341052f2208 100644 --- a/tests/components/mopeka/test_config_flow.py +++ b/tests/components/mopeka/test_config_flow.py @@ -2,8 +2,10 @@ from unittest.mock import patch +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.components.mopeka.const import DOMAIN +from homeassistant.components.mopeka.const import CONF_MEDIUM_TYPE, DOMAIN, MediumType from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,13 +23,14 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" + with patch("homeassistant.components.mopeka.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + result["flow_id"], user_input={CONF_MEDIUM_TYPE: MediumType.PROPANE.value} ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert result2["data"] == {CONF_MEDIUM_TYPE: MediumType.PROPANE.value} assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" @@ -71,7 +74,10 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" @@ -190,8 +196,44 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Pro Plus EEFF" - assert result2["data"] == {} + assert CONF_MEDIUM_TYPE in result2["data"] + assert result2["data"][CONF_MEDIUM_TYPE] in [ + medium_type.value for medium_type in MediumType + ] assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" # Verify the original one was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_async_step_reconfigure_options(hass: HomeAssistant) -> None: + """Test reconfig options: change MediumType from air to fresh water.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:75:10", + title="TD40/TD200 7510", + data={CONF_MEDIUM_TYPE: MediumType.AIR.value}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.data[CONF_MEDIUM_TYPE] == MediumType.AIR.value + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + schema: vol.Schema = result["data_schema"] + medium_type_key = next( + iter(key for key in schema.schema if key == CONF_MEDIUM_TYPE) + ) + assert medium_type_key.default() == MediumType.AIR.value + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MEDIUM_TYPE: MediumType.FRESH_WATER.value}, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + # Verify the new configuration + assert entry.data[CONF_MEDIUM_TYPE] == MediumType.FRESH_WATER.value From 6362ca1052f4042ccf2759dea0293e32a1445b79 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Jul 2024 21:52:25 +0200 Subject: [PATCH 41/42] Bump pyhomeworks to 1.1.0 (#122870) --- homeassistant/components/homeworks/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json index 9b447ef4aea60..1ba0672c9f104 100644 --- a/homeassistant/components/homeworks/manifest.json +++ b/homeassistant/components/homeworks/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homeworks", "iot_class": "local_push", "loggers": ["pyhomeworks"], - "requirements": ["pyhomeworks==1.0.0"] + "requirements": ["pyhomeworks==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e0d502f30007b..80f4ab6bc4a50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1903,7 +1903,7 @@ pyhiveapi==0.5.16 pyhomematic==0.1.77 # homeassistant.components.homeworks -pyhomeworks==1.0.0 +pyhomeworks==1.1.0 # homeassistant.components.ialarm pyialarm==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1037d5109fe9..fa24095e5e2a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1517,7 +1517,7 @@ pyhiveapi==0.5.16 pyhomematic==0.1.77 # homeassistant.components.homeworks -pyhomeworks==1.0.0 +pyhomeworks==1.1.0 # homeassistant.components.ialarm pyialarm==2.2.0 From da18aae2d89f146e49c5cbba288df250d8782e3f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 30 Jul 2024 15:27:16 -0500 Subject: [PATCH 42/42] Bump intents to 2024.7.29 (#122811) --- 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 f308ae5764727..65c79cef187e0 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.7.10"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b0e17bc2826ff..70c05f35c3312 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==20240719.0 -home-assistant-intents==2024.7.10 +home-assistant-intents==2024.7.29 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 80f4ab6bc4a50..c9e5ad7c2647a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1093,7 +1093,7 @@ holidays==0.53 home-assistant-frontend==20240719.0 # homeassistant.components.conversation -home-assistant-intents==2024.7.10 +home-assistant-intents==2024.7.29 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa24095e5e2a5..33c1ebcdb09aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -916,7 +916,7 @@ holidays==0.53 home-assistant-frontend==20240719.0 # homeassistant.components.conversation -home-assistant-intents==2024.7.10 +home-assistant-intents==2024.7.29 # homeassistant.components.home_connect homeconnect==0.8.0