From b4c36d46768dd45827847625ed5f067067197c7f Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:27:51 +0100 Subject: [PATCH 01/15] Bump pytedee_async to 0.2.17 (#113933) --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 1f2a2405a445b8..db3a88f3113bcf 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "loggers": ["pytedee_async"], - "requirements": ["pytedee-async==0.2.16"] + "requirements": ["pytedee-async==0.2.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ae4d9df333283..f4e90398863010 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2175,7 +2175,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.16 +pytedee-async==0.2.17 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f8e59bd81e5fd..557596dccba2ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1696,7 +1696,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.16 +pytedee-async==0.2.17 # homeassistant.components.motionmount python-MotionMount==0.3.1 From 63221356f61c13bfe9d5b8498b4edaf897f7fa91 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 21 Mar 2024 17:07:09 +0100 Subject: [PATCH 02/15] Add select platform to Husqvarna Automower (#113816) * Add select platform to Husqvarna Automower * docstring * address review * pin headlight_modes list * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Apply review --------- Co-authored-by: Joost Lekkerkerker --- .../husqvarna_automower/__init__.py | 1 + .../components/husqvarna_automower/icons.json | 5 + .../components/husqvarna_automower/select.py | 70 ++++++++++++ .../husqvarna_automower/strings.json | 17 ++- .../husqvarna_automower/test_select.py | 102 ++++++++++++++++++ 5 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/select.py create mode 100644 tests/components/husqvarna_automower/test_select.py diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index a6c9e46dbed7a4..03ab02429bb35e 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -21,6 +21,7 @@ Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.LAWN_MOWER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index a3e2bd6bb8bd02..65cc85bd09bf69 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,11 @@ "default": "mdi:debug-step-into" } }, + "select": { + "headlight_mode": { + "default": "mdi:car-light-high" + } + }, "sensor": { "number_of_charging_cycles": { "default": "mdi:battery-sync-outline" diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py new file mode 100644 index 00000000000000..e4376a1bca5ad8 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/select.py @@ -0,0 +1,70 @@ +"""Creates a select entity for the headlight of the mower.""" + +import logging + +from aioautomower.exceptions import ApiException +from aioautomower.model import HeadlightModes + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerControlEntity + +_LOGGER = logging.getLogger(__name__) + + +HEADLIGHT_MODES: list = [ + HeadlightModes.ALWAYS_OFF.lower(), + HeadlightModes.ALWAYS_ON.lower(), + HeadlightModes.EVENING_AND_NIGHT.lower(), + HeadlightModes.EVENING_ONLY.lower(), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up select platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerSelectEntity(mower_id, coordinator) + for mower_id in coordinator.data + if coordinator.data[mower_id].capabilities.headlights + ) + + +class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): + """Defining the headlight mode entity.""" + + _attr_options = HEADLIGHT_MODES + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "headlight_mode" + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up select platform.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{mower_id}_headlight_mode" + + @property + def current_option(self) -> str: + """Return the current option for the entity.""" + return self.mower_attributes.headlight.mode.lower() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self.coordinator.api.set_headlight_mode(self.mower_id, option.upper()) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 4280ea097e85ff..8032c67040443e 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -37,9 +37,15 @@ "name": "Returning to dock" } }, - "switch": { - "enable_schedule": { - "name": "Enable schedule" + "select": { + "headlight_mode": { + "name": "Headlight mode", + "state": { + "always_on": "Always on", + "always_off": "Always off", + "evening_only": "Evening only", + "evening_and_night": "Evening and night" + } } }, "sensor": { @@ -79,6 +85,11 @@ "demo": "Demo" } } + }, + "switch": { + "enable_schedule": { + "name": "Enable schedule" + } } } } diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py new file mode 100644 index 00000000000000..4283c7d379751b --- /dev/null +++ b/tests/components/husqvarna_automower/test_select.py @@ -0,0 +1,102 @@ +"""Tests for select platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from aioautomower.exceptions import ApiException +from aioautomower.model import HeadlightModes +from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, +) + + +async def test_select_states( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test states of headlight mode select.""" + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + state = hass.states.get("select.test_mower_1_headlight_mode") + assert state is not None + assert state.state == "evening_only" + + for state, expected_state in [ + ( + HeadlightModes.ALWAYS_OFF, + "always_off", + ), + (HeadlightModes.ALWAYS_ON, "always_on"), + (HeadlightModes.EVENING_AND_NIGHT, "evening_and_night"), + ]: + values[TEST_MOWER_ID].headlight.mode = state + mock_automower_client.get_status.return_value = values + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("select.test_mower_1_headlight_mode") + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service"), + [ + ("always_on"), + ("always_off"), + ("evening_only"), + ("evening_and_night"), + ], +) +async def test_select_commands( + hass: HomeAssistant, + service: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test select commands for headlight mode.""" + await setup_integration(hass, mock_config_entry) + await hass.services.async_call( + domain="select", + service="select_option", + service_data={ + "entity_id": "select.test_mower_1_headlight_mode", + "option": service, + }, + blocking=True, + ) + mocked_method = mock_automower_client.set_headlight_mode + assert len(mocked_method.mock_calls) == 1 + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + domain="select", + service="select_option", + service_data={ + "entity_id": "select.test_mower_1_headlight_mode", + "option": service, + }, + blocking=True, + ) + assert ( + str(exc_info.value) + == "Command couldn't be sent to the command queue: Test error" + ) + assert len(mocked_method.mock_calls) == 2 From 8141a246b0af1a75d7b6bea24d5a718ca182e8ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Mar 2024 08:54:24 -1000 Subject: [PATCH 03/15] Remove unrelated patching from scrape test (#113951) https://github.com/home-assistant/core/pull/105516#discussion_r1534459365 The fixture already is designed to go unavailable on the 3rd update --- tests/components/scrape/test_sensor.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index b2ee75c2172928..41da2eb9a79101 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -615,13 +615,10 @@ async def test_availability( hass.states.async_set("sensor.input1", "off") await hass.async_block_till_done() - with patch( - "homeassistant.components.command_line.utils.subprocess.check_output", - return_value=b"0", - ): - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("sensor.current_version") assert state.state == STATE_UNAVAILABLE From 8728057b1b39c647a7c8d449d7fa97acdab4731a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 21 Mar 2024 19:58:56 +0100 Subject: [PATCH 04/15] Add support for Shelly RPC devices custom TCP port (#110860) * First coding * add port to config_entry + gen1 not supported msg * fix async_step_credentials * strings * fix reauth * fix visit device link * increased MINOR_VERSION * apply review comments * align to latest aioshelly * missing tests * introduce port parameter * update tests * remove leftover * remove "port" data_description key * missing key * apply review comments * apply more review comments * Add tests * apply review comment * apply review comment (part 2) * description update * fine tuning description * fix test patching --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 2 + .../components/shelly/config_flow.py | 60 +++++++++++++------ .../components/shelly/coordinator.py | 3 +- homeassistant/components/shelly/strings.json | 9 ++- homeassistant/components/shelly/utils.py | 9 ++- tests/components/shelly/test_config_flow.py | 57 +++++++++++++++--- tests/components/shelly/test_init.py | 48 ++++++++++++++- 7 files changed, 156 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index cfa95162eed9c5..523c6a67433710 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,6 +56,7 @@ get_block_device_sleep_period, get_coap_context, get_device_entry_gen, + get_http_port, get_rpc_device_wakeup_period, get_ws_context, ) @@ -249,6 +250,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD), device_mac=entry.unique_id, + port=get_http_port(entry.data), ) ws_context = await get_ws_context(hass) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index ca56552d125fb0..ca1190de708763 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,8 +7,9 @@ from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS +from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS from aioshelly.exceptions import ( + CustomPortNotSupported, DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, @@ -23,7 +24,7 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig @@ -42,6 +43,7 @@ get_block_device_sleep_period, get_coap_context, get_device_entry_gen, + get_http_port, get_info_auth, get_info_gen, get_model_name, @@ -50,7 +52,12 @@ mac_address_from_name, ) -HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) +CONFIG_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_HTTP_PORT): vol.Coerce(int), + } +) BLE_SCANNER_OPTIONS = [ @@ -65,14 +72,20 @@ async def validate_input( hass: HomeAssistant, host: str, + port: int, info: dict[str, Any], data: dict[str, Any], ) -> dict[str, Any]: """Validate the user input allows us to connect. - Data has the keys from HOST_SCHEMA with values provided by the user. + Data has the keys from CONFIG_SCHEMA with values provided by the user. """ - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + options = ConnectionOptions( + ip_address=host, + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + port=port, + ) gen = get_info_gen(info) @@ -114,8 +127,10 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Shelly.""" VERSION = 1 + MINOR_VERSION = 2 host: str = "" + port: int = DEFAULT_HTTP_PORT info: dict[str, Any] = {} device_info: dict[str, Any] = {} entry: ConfigEntry | None = None @@ -126,9 +141,10 @@ async def async_step_user( """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - host: str = user_input[CONF_HOST] + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] try: - self.info = await self._async_get_info(host) + self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" except FirmwareUnsupported: @@ -140,15 +156,18 @@ async def async_step_user( await self.async_set_unique_id(self.info["mac"]) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host + self.port = port if get_info_auth(self.info): return await self.async_step_credentials() try: device_info = await validate_input( - self.hass, self.host, self.info, {} + self.hass, host, port, self.info, {} ) except DeviceConnectionError: errors["base"] = "cannot_connect" + except CustomPortNotSupported: + errors["base"] = "custom_port_not_supported" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -157,7 +176,8 @@ async def async_step_user( return self.async_create_entry( title=device_info["title"], data={ - **user_input, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], CONF_GEN: device_info[CONF_GEN], @@ -166,7 +186,7 @@ async def async_step_user( errors["base"] = "firmware_not_fully_provisioned" return self.async_show_form( - step_id="user", data_schema=HOST_SCHEMA, errors=errors + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) async def async_step_credentials( @@ -179,7 +199,7 @@ async def async_step_credentials( user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( - self.hass, self.host, self.info, user_input + self.hass, self.host, self.port, self.info, user_input ) except InvalidAuthError: errors["base"] = "invalid_auth" @@ -195,6 +215,7 @@ async def async_step_credentials( data={ **user_input, CONF_HOST: self.host, + CONF_PORT: self.port, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], "model": device_info["model"], CONF_GEN: device_info[CONF_GEN], @@ -254,7 +275,9 @@ async def async_step_zeroconf( await self._async_discovered_mac(mac, host) try: - self.info = await self._async_get_info(host) + # Devices behind range extender doesn't generate zeroconf packets + # so port is always the default one + self.info = await self._async_get_info(host, DEFAULT_HTTP_PORT) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") except FirmwareUnsupported: @@ -277,7 +300,9 @@ async def async_step_zeroconf( return await self.async_step_credentials() try: - self.device_info = await validate_input(self.hass, self.host, self.info, {}) + self.device_info = await validate_input( + self.hass, self.host, self.port, self.info, {} + ) except DeviceConnectionError: return self.async_abort(reason="cannot_connect") @@ -329,17 +354,18 @@ async def async_step_reauth_confirm( errors: dict[str, str] = {} assert self.entry is not None host = self.entry.data[CONF_HOST] + port = get_http_port(self.entry.data) if user_input is not None: try: - info = await self._async_get_info(host) + info = await self._async_get_info(host, port) except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") if get_device_entry_gen(self.entry) != 1: user_input[CONF_USERNAME] = "admin" try: - await validate_input(self.hass, host, info, user_input) + await validate_input(self.hass, host, port, info, user_input) except (DeviceConnectionError, InvalidAuthError, FirmwareUnsupported): return self.async_abort(reason="reauth_unsuccessful") @@ -361,9 +387,9 @@ async def async_step_reauth_confirm( errors=errors, ) - async def _async_get_info(self, host: str) -> dict[str, Any]: + async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: """Get info from shelly device.""" - return await get_info(async_get_clientsession(self.hass), host) + return await get_info(async_get_clientsession(self.hass), host, port=port) @staticmethod @callback diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index ebc92a6c6e04ff..50c352bcb25c2d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -59,6 +59,7 @@ ) from .utils import ( get_device_entry_gen, + get_http_port, get_rpc_device_wakeup_period, update_device_fw_info, ) @@ -140,7 +141,7 @@ def async_setup(self) -> None: model=MODEL_NAMES.get(self.model, self.model), sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.entry)} ({self.model})", - configuration_url=f"http://{self.entry.data[CONF_HOST]}", + configuration_url=f"http://{self.entry.data[CONF_HOST]}:{get_http_port(self.entry.data)}", ) self.device_id = device_entry.id diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9676c24f8830ca..d4a8b117f4c724 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -5,10 +5,12 @@ "user": { "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { - "host": "[%key:common::config_flow::data::host%]" + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" }, "data_description": { - "host": "The hostname or IP address of the Shelly device to connect to." + "host": "The hostname or IP address of the Shelly device to connect to.", + "port": "The TCP port of the Shelly device to connect to (Gen2+)." } }, "credentials": { @@ -31,7 +33,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support" + "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", + "custom_port_not_supported": "Gen1 device does not support custom port." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 291fe0cc4ea328..dd0e685fd67a38 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from ipaddress import IPv4Address +from types import MappingProxyType from typing import Any, cast from aiohttp.web import Request, WebSocketResponse @@ -11,6 +12,7 @@ from aioshelly.const import ( BLOCK_GENERATIONS, DEFAULT_COAP_PORT, + DEFAULT_HTTP_PORT, MODEL_1L, MODEL_DIMMER, MODEL_DIMMER_2, @@ -24,7 +26,7 @@ from homeassistant.components import network from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir, singleton from homeassistant.helpers.device_registry import ( @@ -473,3 +475,8 @@ def is_rpc_wifi_stations_disabled( return False return True + + +def get_http_port(data: MappingProxyType[str, Any]) -> int: + """Get port from config entry data.""" + return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 1a680bcfc68755..99b30062d4321b 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -6,8 +6,9 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch -from aioshelly.const import MODEL_1, MODEL_PLUS_2PM +from aioshelly.const import DEFAULT_HTTP_PORT, MODEL_1, MODEL_PLUS_2PM from aioshelly.exceptions import ( + CustomPortNotSupported, DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, @@ -54,17 +55,18 @@ @pytest.mark.parametrize( - ("gen", "model"), + ("gen", "model", "port"), [ - (1, MODEL_1), - (2, MODEL_PLUS_2PM), - (3, MODEL_PLUS_2PM), + (1, MODEL_1, DEFAULT_HTTP_PORT), + (2, MODEL_PLUS_2PM, DEFAULT_HTTP_PORT), + (3, MODEL_PLUS_2PM, 11200), ], ) async def test_form( hass: HomeAssistant, gen: int, model: str, + port: int, mock_block_device: Mock, mock_rpc_device: Mock, ) -> None: @@ -72,12 +74,18 @@ async def test_form( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, + return_value={ + "mac": "test-mac", + "type": MODEL_1, + "auth": False, + "gen": gen, + "port": port, + }, ), patch( "homeassistant.components.shelly.async_setup", return_value=True ) as mock_setup, patch( @@ -86,7 +94,7 @@ async def test_form( ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {"host": "1.1.1.1", "port": port}, ) await hass.async_block_till_done() @@ -94,6 +102,7 @@ async def test_form( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "port": port, "model": model, "sleep_period": 0, "gen": gen, @@ -102,6 +111,33 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_gen1_custom_port( + hass: HomeAssistant, + mock_block_device: Mock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "gen": 1}, + ), patch( + "aioshelly.block_device.BlockDevice.create", + side_effect=CustomPortNotSupported, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1", "port": "1100"}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"]["base"] == "custom_port_not_supported" + + @pytest.mark.parametrize( ("gen", "model", "user_input", "username"), [ @@ -168,6 +204,7 @@ async def test_form_auth( assert result3["title"] == "Test name" assert result3["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": model, "sleep_period": 0, "gen": gen, @@ -757,6 +794,7 @@ async def test_zeroconf_require_auth( assert result2["title"] == "Test name" assert result2["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": MODEL_1, "sleep_period": 0, "gen": 1, @@ -1126,7 +1164,7 @@ async def test_sleeping_device_gen2_with_new_firmware( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with patch( @@ -1144,6 +1182,7 @@ async def test_sleeping_device_gen2_with_new_firmware( assert result["data"] == { "host": "1.1.1.1", + "port": DEFAULT_HTTP_PORT, "model": MODEL_PLUS_2PM, "sleep_period": 666, "gen": 2, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 700b54f153df7d..eb621a6e044abc 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -4,6 +4,8 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP +from aioshelly.common import ConnectionOptions +from aioshelly.const import MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, @@ -16,13 +18,14 @@ BLOCK_EXPECTED_SLEEP_PERIOD, BLOCK_WRONG_SLEEP_PERIOD, CONF_BLE_SCANNER_MODE, + CONF_GEN, CONF_SLEEP_PERIOD, DOMAIN, MODELS_WITH_WRONG_SLEEP_PERIOD, BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, @@ -392,6 +395,49 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device: Mock) - assert hass.states.get("switch.test_name_channel_1").state is STATE_ON +async def test_entry_missing_port(hass: HomeAssistant) -> None: + """Test successful Gen2 device init when port is missing in entry data.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + "model": MODEL_PLUS_2PM, + CONF_GEN: 2, + } + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.shelly.RpcDevice.create", return_value=Mock() + ) as rpc_device_mock: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert rpc_device_mock.call_args[0][2] == ConnectionOptions( + ip_address="192.168.1.37", device_mac="123456789ABC", port=80 + ) + + +async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: + """Test successful Gen2 device init using custom port.""" + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: 0, + "model": MODEL_PLUS_2PM, + CONF_GEN: 2, + CONF_PORT: 8001, + } + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.shelly.RpcDevice.create", return_value=Mock() + ) as rpc_device_mock: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert rpc_device_mock.call_args[0][2] == ConnectionOptions( + ip_address="192.168.1.37", device_mac="123456789ABC", port=8001 + ) + + @pytest.mark.parametrize(("model"), MODELS_WITH_WRONG_SLEEP_PERIOD) async def test_sleeping_block_device_wrong_sleep_period( hass: HomeAssistant, mock_block_device: Mock, model: str From 63275d61a5d57085b2b9fb4a5fa10e3d36108897 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 21 Mar 2024 21:04:50 +0200 Subject: [PATCH 05/15] Add Shelly RGB/RGBW profiles support (#113808) * Add Shelly RGB/RGBW profiles support * Update homeassistant/components/shelly/light.py Co-authored-by: Robert Svensson * Use walrus in rgbw_key_ids * Use walrus in light_key_ids --------- Co-authored-by: Robert Svensson --- homeassistant/components/shelly/const.py | 7 +- homeassistant/components/shelly/light.py | 118 ++++++++++++++------ tests/components/shelly/conftest.py | 4 + tests/components/shelly/test_light.py | 131 +++++++++++++++++++++++ 4 files changed, 224 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 53e827cea72759..3580bcf9b38bda 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -35,8 +35,11 @@ CONF_COAP_PORT: Final = "coap_port" FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# max light transition time in milliseconds -MAX_TRANSITION_TIME: Final = 5000 +# max BLOCK light transition time in milliseconds (min=0) +BLOCK_MAX_TRANSITION_TIME_MS: Final = 5000 + +# min RPC light transition time in seconds (max=10800, limited by light entity to 6553) +RPC_MIN_TRANSITION_TIME_SEC = 0.5 RGBW_MODELS: Final = ( MODEL_BULB, diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 7c465e43db09d8..6c28023a5e3375 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -24,14 +24,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + BLOCK_MAX_TRANSITION_TIME_MS, DUAL_MODE_LIGHT_MODELS, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, LOGGER, - MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, RGBW_MODELS, + RPC_MIN_TRANSITION_TIME_SEC, SHBLB_1_RGB_EFFECTS, STANDARD_RGB_EFFECTS, ) @@ -116,9 +117,16 @@ def async_setup_rpc_entry( ) return - light_key_ids = get_rpc_key_ids(coordinator.device.status, "light") - if light_key_ids: + if light_key_ids := get_rpc_key_ids(coordinator.device.status, "light"): async_add_entities(RpcShellyLight(coordinator, id_) for id_ in light_key_ids) + return + + if rgb_key_ids := get_rpc_key_ids(coordinator.device.status, "rgb"): + async_add_entities(RpcShellyRgbLight(coordinator, id_) for id_ in rgb_key_ids) + return + + if rgbw_key_ids := get_rpc_key_ids(coordinator.device.status, "rgbw"): + async_add_entities(RpcShellyRgbwLight(coordinator, id_) for id_ in rgbw_key_ids) class BlockShellyLight(ShellyBlockEntity, LightEntity): @@ -280,7 +288,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: params["transition"] = min( - int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS ) if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes): @@ -352,7 +360,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: if ATTR_TRANSITION in kwargs: params["transition"] = min( - int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME + int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS ) self.control_result = await self.set_state(**params) @@ -366,15 +374,14 @@ def _update_callback(self) -> None: super()._update_callback() -class RpcShellySwitchAsLight(ShellyRpcEntity, LightEntity): - """Entity that controls a relay as light on RPC based Shelly devices.""" +class RpcShellyLightBase(ShellyRpcEntity, LightEntity): + """Base Entity for RPC based Shelly devices.""" - _attr_color_mode = ColorMode.ONOFF - _attr_supported_color_modes = {ColorMode.ONOFF} + _component: str = "Light" def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize light.""" - super().__init__(coordinator, f"switch:{id_}") + super().__init__(coordinator, f"{self._component.lower()}:{id_}") self._id = id_ @property @@ -382,45 +389,88 @@ def is_on(self) -> bool: """If light is on.""" return bool(self.status["output"]) + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return percentage_to_brightness(self.status["brightness"]) + + @property + def rgb_color(self) -> tuple[int, int, int]: + """Return the rgb color value [int, int, int].""" + return cast(tuple, self.status["rgb"]) + + @property + def rgbw_color(self) -> tuple[int, int, int, int]: + """Return the rgbw color value [int, int, int, int].""" + return (*self.status["rgb"], self.status["white"]) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) + params: dict[str, Any] = {"id": self._id, "on": True} + + if ATTR_BRIGHTNESS in kwargs: + params["brightness"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS]) + + if ATTR_TRANSITION in kwargs: + params["transition_duration"] = max( + kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC + ) + + if ATTR_RGB_COLOR in kwargs: + params["rgb"] = list(kwargs[ATTR_RGB_COLOR]) + + if ATTR_RGBW_COLOR in kwargs: + params["rgb"] = list(kwargs[ATTR_RGBW_COLOR][:-1]) + params["white"] = kwargs[ATTR_RGBW_COLOR][-1] + + await self.call_rpc(f"{self._component}.Set", params) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) + params: dict[str, Any] = {"id": self._id, "on": False} + if ATTR_TRANSITION in kwargs: + params["transition_duration"] = max( + kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC + ) + + await self.call_rpc(f"{self._component}.Set", params) + + +class RpcShellySwitchAsLight(RpcShellyLightBase): + """Entity that controls a relay as light on RPC based Shelly devices.""" + + _component = "Switch" -class RpcShellyLight(ShellyRpcEntity, LightEntity): + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + +class RpcShellyLight(RpcShellyLightBase): """Entity that controls a light on RPC based Shelly devices.""" + _component = "Light" + _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_supported_features = LightEntityFeature.TRANSITION - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize light.""" - super().__init__(coordinator, f"light:{id_}") - self._id = id_ - @property - def is_on(self) -> bool: - """If light is on.""" - return bool(self.status["output"]) +class RpcShellyRgbLight(RpcShellyLightBase): + """Entity that controls a RGB light on RPC based Shelly devices.""" - @property - def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" - return percentage_to_brightness(self.status["brightness"]) + _component = "RGB" - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on light.""" - params: dict[str, Any] = {"id": self._id, "on": True} + _attr_color_mode = ColorMode.RGB + _attr_supported_color_modes = {ColorMode.RGB} + _attr_supported_features = LightEntityFeature.TRANSITION - if ATTR_BRIGHTNESS in kwargs: - params["brightness"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS]) - await self.call_rpc("Light.Set", params) +class RpcShellyRgbwLight(RpcShellyLightBase): + """Entity that controls a RGBW light on RPC based Shelly devices.""" - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off light.""" - await self.call_rpc("Light.Set", {"id": self._id, "on": False}) + _component = "RGBW" + + _attr_color_mode = ColorMode.RGBW + _attr_supported_color_modes = {ColorMode.RGBW} + _attr_supported_features = LightEntityFeature.TRANSITION diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index df6a5f41306f8d..9e8dd3999a6ed8 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -169,6 +169,8 @@ def mock_white_light_set_state( "input:1": {"id": 1, "type": "analog", "enable": True}, "input:2": {"id": 2, "name": "Gas", "type": "count", "enable": True}, "light:0": {"name": "test light_0"}, + "rgb:0": {"name": "test rgb_0"}, + "rgbw:0": {"name": "test rgbw_0"}, "switch:0": {"name": "test switch_0"}, "cover:0": {"name": "test cover_0"}, "thermostat:0": { @@ -223,6 +225,8 @@ def mock_white_light_set_state( "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": {"id": 2, "counts": {"total": 56174, "xtotal": 561.74}}, "light:0": {"output": True, "brightness": 53.0}, + "rgb:0": {"output": True, "brightness": 53.0, "rgb": [45, 55, 65]}, + "rgbw:0": {"output": True, "brightness": 53.0, "rgb": [21, 22, 23], "white": 120}, "cloud": {"connected": False}, "cover:0": { "state": "stopped", diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 2c7eda3a1e01cf..cca318c364ddf1 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -539,6 +539,137 @@ async def test_rpc_light( assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 33 + # Turn on, transition = 10.1 + mock_rpc_device.call_rpc.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 10.1}, + blocking=True, + ) + + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Light.Set", {"id": 0, "on": True, "transition_duration": 10.1} + ) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Turn off, transition = 0.4, should be limited to 0.5 + mock_rpc_device.call_rpc.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 0.4}, + blocking=True, + ) + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "light:0", "output", False) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "Light.Set", {"id": 0, "on": False, "transition_duration": 0.5} + ) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-light:0" + + +async def test_rpc_device_rgb_profile( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device in RGB profile.""" + monkeypatch.delitem(mock_rpc_device.status, "light:0") + monkeypatch.delitem(mock_rpc_device.status, "rgbw:0") + entity_id = "light.test_rgb_0" + await init_integration(hass, 2) + + # Test initial + state = hass.states.get(entity_id) + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGB_COLOR] == (45, 55, 65) + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + + # Turn on, RGB = [70, 80, 90] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: [70, 80, 90]}, + blocking=True, + ) + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "rgb:0", "rgb", [70, 80, 90]) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "RGB.Set", {"id": 0, "on": True, "rgb": [70, 80, 90]} + ) + + state = hass.states.get(entity_id) + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB + assert attributes[ATTR_RGB_COLOR] == (70, 80, 90) + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-rgb:0" + + +async def test_rpc_device_rgbw_profile( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC device in RGBW profile.""" + monkeypatch.delitem(mock_rpc_device.status, "light:0") + monkeypatch.delitem(mock_rpc_device.status, "rgb:0") + entity_id = "light.test_rgbw_0" + await init_integration(hass, 2) + + # Test initial + state = hass.states.get(entity_id) + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120) + assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION + + # Turn on, RGBW = [72, 82, 92, 128] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: [72, 82, 92, 128]}, + blocking=True, + ) + + mutate_rpc_device_status( + monkeypatch, mock_rpc_device, "rgbw:0", "rgb", [72, 82, 92] + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "rgbw:0", "white", 128) + mock_rpc_device.mock_update() + + mock_rpc_device.call_rpc.assert_called_once_with( + "RGBW.Set", {"id": 0, "on": True, "rgb": [72, 82, 92], "white": 128} + ) + + state = hass.states.get(entity_id) + attributes = state.attributes + assert state.state == STATE_ON + assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW + assert attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128) + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-rgbw:0" From d0708b5b320095bfa9994977f8a541a71fe8641d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Mar 2024 09:10:47 -1000 Subject: [PATCH 06/15] Fix grammar in async_get_platform comment (#113948) https://github.com/home-assistant/core/pull/113917#pullrequestreview-1951203739 --- homeassistant/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 7c52787b34ad06..ae823d9a204b65 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1065,8 +1065,8 @@ def _load_platforms(self, platform_names: Iterable[str]) -> dict[str, ModuleType async def async_get_platform(self, platform_name: str) -> ModuleType: """Return a platform for an integration.""" - # Fast path for a single platform when its already - # cached. This is the common case. + # Fast path for a single platform when it is already cached. + # This is the common case. if platform := self._cache.get(f"{self.domain}.{platform_name}"): return platform # type: ignore[return-value] platforms = await self.async_get_platforms((platform_name,)) From 9a863638f6fd7ef1ac30d0c6159eed1be6f39e02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Mar 2024 09:19:55 -1000 Subject: [PATCH 07/15] Avoid writing HomeKit state to disk unless its missing (#111970) --- homeassistant/components/homekit/__init__.py | 24 ++++++++++++++++---- tests/components/homekit/test_homekit.py | 9 +++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index a5c08a3bbd40af..7d1297e13077d3 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -550,8 +550,13 @@ def __init__( self._reset_lock = asyncio.Lock() self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None - def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: - """Set up bridge and accessory driver.""" + def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> bool: + """Set up bridge and accessory driver. + + Returns True if data was loaded from disk + + Returns False if the persistent data was not loaded + """ assert self.iid_storage is not None persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( @@ -573,6 +578,9 @@ def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: # as pyhap uses a random one until state is restored if os.path.exists(persist_file): self.driver.load() + return True + + return False async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None: """Reset the accessory to load the latest configuration.""" @@ -842,7 +850,9 @@ async def async_start(self, *args: Any) -> None: # Avoid gather here since it will be I/O bound anyways await self.aid_storage.async_initialize() await self.iid_storage.async_initialize() - await self.hass.async_add_executor_job(self.setup, async_zc_instance, uuid) + loaded_from_disk = await self.hass.async_add_executor_job( + self.setup, async_zc_instance, uuid + ) assert self.driver is not None if not await self._async_create_accessories(): @@ -850,8 +860,12 @@ async def async_start(self, *args: Any) -> None: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() - async with self.hass.data[PERSIST_LOCK_DATA]: - await self.hass.async_add_executor_job(self.driver.persist) + if not loaded_from_disk: + # If the state was not loaded from disk, it means this is the + # first time the bridge is ever starting up. In this case, we + # need to make sure its persisted to disk. + async with self.hass.data[PERSIST_LOCK_DATA]: + await self.hass.async_add_executor_job(self.driver.persist) self.status = STATUS_RUNNING if self.driver.state.paired: diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index a6748749e1a1e2..5ca5b5e386f648 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -765,9 +765,16 @@ async def test_homekit_start( f"{PATH_HOMEKIT}.async_show_setup_message" ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" - ) as hk_driver_start: + ) as hk_driver_start, patch( + "pyhap.accessory_driver.AccessoryDriver.load" + ) as load_mock, patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ) as persist_mock, patch(f"{PATH_HOMEKIT}.os.path.exists", return_value=True): + await homekit.async_stop() await homekit.async_start() + assert load_mock.called + assert not persist_mock.called device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) From 0c791051b86cfda8f6220fa1fecacea9a18494e8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 21 Mar 2024 21:42:42 +0100 Subject: [PATCH 08/15] Bump axis to v57 (#113952) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 346afc4b4feaab..44d615bf5346b9 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==56"], + "requirements": ["axis==57"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index f4e90398863010..07dab3fc6e22e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -511,7 +511,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==56 +axis==57 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 557596dccba2ab..c96de54a87b15b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==56 +axis==57 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From a6d98c18570c6ce88be414ed6ce2739d1d174758 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:54:45 +0000 Subject: [PATCH 09/15] Improve user error messages for generic camera (#112814) * Generic camera: Insufficient error message when configuration fails Fixes #112279 * Add tests * Fix typo in string * Add new error strings to options flow. * Group and improve error strings following PR review --- .../components/generic/config_flow.py | 17 +++++++- homeassistant/components/generic/strings.json | 8 ++++ tests/components/generic/test_config_flow.py | 42 ++++++++++++++++--- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 621b4dd299b572..8fdc01437002bb 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -183,14 +183,27 @@ async def async_test_still( except ( TimeoutError, RequestError, - HTTPStatusError, TimeoutException, ) as err: _LOGGER.error("Error getting camera image from %s: %s", url, type(err).__name__) return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None + except HTTPStatusError as err: + _LOGGER.error( + "Error getting camera image from %s: %s %s", + url, + type(err).__name__, + err.response.text, + ) + if err.response.status_code in [401, 403]: + return {CONF_STILL_IMAGE_URL: "unable_still_load_auth"}, None + if err.response.status_code in [404]: + return {CONF_STILL_IMAGE_URL: "unable_still_load_not_found"}, None + if err.response.status_code in [500, 503]: + return {CONF_STILL_IMAGE_URL: "unable_still_load_server_error"}, None + return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None if not image: - return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None + return {CONF_STILL_IMAGE_URL: "unable_still_load_no_image"}, None fmt = get_image_type(image) _LOGGER.debug( "Still image at '%s' detected format: %s", diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index a1519fa0f48d0e..991a36d49cc988 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -5,6 +5,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "already_exists": "A camera with these URL settings already exists.", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", + "unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.", + "unable_still_load_not_found": "Unable to load valid image from still image URL: The URL was not found on the server.", + "unable_still_load_server_error": "Unable to load valid image from still image URL: The camera replied with a server error.", + "unable_still_load_no_image": "Unable to load valid image from still image URL: No image was returned.", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", "invalid_still_image": "URL did not return a valid still image", "malformed_url": "Malformed URL", @@ -73,6 +77,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "already_exists": "[%key:component::generic::config::error::already_exists%]", "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", + "unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]", + "unable_still_load_not_found": "[%key:component::generic::config::error::unable_still_load_not_found%]", + "unable_still_load_server_error": "[%key:component::generic::config::error::unable_still_load_server_error%]", + "unable_still_load_no_image": "[%key:component::generic::config::error::unable_still_load_no_image%]", "no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]", "invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]", "malformed_url": "[%key:component::generic::config::error::malformed_url%]", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 6ced50862829af..7eee5f1bd9e141 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -449,12 +449,42 @@ async def test_form_still_and_stream_not_provided( @respx.mock -async def test_form_image_timeout( - hass: HomeAssistant, user_flow, mock_create_stream +@pytest.mark.parametrize( + ("side_effect", "expected_message"), + [ + (httpx.TimeoutException, {"still_image_url": "unable_still_load"}), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(401)), + {"still_image_url": "unable_still_load_auth"}, + ), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(403)), + {"still_image_url": "unable_still_load_auth"}, + ), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(404)), + {"still_image_url": "unable_still_load_not_found"}, + ), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(500)), + {"still_image_url": "unable_still_load_server_error"}, + ), + ( + httpx.HTTPStatusError("", request=None, response=httpx.Response(503)), + {"still_image_url": "unable_still_load_server_error"}, + ), + ( # Errors without specific handler should show the general message. + httpx.HTTPStatusError("", request=None, response=httpx.Response(507)), + {"still_image_url": "unable_still_load"}, + ), + ], +) +async def test_form_image_http_exceptions( + side_effect, expected_message, hass: HomeAssistant, user_flow, mock_create_stream ) -> None: - """Test we handle invalid image timeout.""" + """Test we handle image http exceptions.""" respx.get("http://127.0.0.1/testurl/1").side_effect = [ - httpx.TimeoutException, + side_effect, ] with mock_create_stream: @@ -465,7 +495,7 @@ async def test_form_image_timeout( await hass.async_block_till_done() assert result2["type"] == "form" - assert result2["errors"] == {"still_image_url": "unable_still_load"} + assert result2["errors"] == expected_message @respx.mock @@ -499,7 +529,7 @@ async def test_form_stream_invalidimage2( await hass.async_block_till_done() assert result2["type"] == "form" - assert result2["errors"] == {"still_image_url": "unable_still_load"} + assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @respx.mock From 4da701a8e9ee880fbbfbaec24c237e1e6146a509 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Mar 2024 23:12:25 +0100 Subject: [PATCH 10/15] Add guard to HomeAssistantError `__str__` method to prevent a recursive loop (#113913) * Add guard to HomeAssistantError `__str__` method to prevent a recursive loop * Use repr of class instance instead * Apply suggestion to explain __str__ method is missing --------- Co-authored-by: J. Nick Koston --- homeassistant/exceptions.py | 9 +++ tests/test_exceptions.py | 156 ++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 81856f27c450bb..a58f683137b6ed 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -64,6 +64,15 @@ def __str__(self) -> str: return self._message if not self.generate_message: + # Initialize self._message to the string repr of the class + # to prevent a recursive loop. + self._message = ( + f"Parent class {self.__class__.__name__} is missing __str__ method" + ) + # If the there is an other super class involved, + # we want to call its __str__ method. + # If the super().__str__ method is missing in the base_class + # the call will be recursive and we return our initialized default. self._message = super().__str__() return self._message diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index f5b60627ce2979..e5fd31c3b4498c 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -119,3 +119,159 @@ async def test_home_assistant_error( assert str(exc.value) == message # Get string of exception again from the cache assert str(exc.value) == message + + +async def test_home_assistant_error_subclass(hass: HomeAssistant) -> None: + """Test __str__ method on an HomeAssistantError subclass.""" + + class _SubExceptionDefault(HomeAssistantError): + """Sub class, default with generated message.""" + + class _SubExceptionConstructor(HomeAssistantError): + """Sub class with constructor, no generated message.""" + + def __init__( + self, + custom_arg: str, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + super().__init__( + self, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.custom_arg = custom_arg + + class _SubExceptionConstructorGenerate(HomeAssistantError): + """Sub class with constructor, with generated message.""" + + generate_message: bool = True + + def __init__( + self, + custom_arg: str, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + ) -> None: + super().__init__( + self, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.custom_arg = custom_arg + + class _SubExceptionGenerate(HomeAssistantError): + """Sub class, no generated message.""" + + generate_message: bool = True + + class _SubClassWithExceptionGroup(HomeAssistantError, BaseExceptionGroup): + """Sub class with exception group, no generated message.""" + + class _SubClassWithExceptionGroupGenerate(HomeAssistantError, BaseExceptionGroup): + """Sub class with exception group and generated message.""" + + generate_message: bool = True + + with patch( + "homeassistant.helpers.translation.async_get_cached_translations", + return_value={"component.test.exceptions.bla.message": "{bla} from cache"}, + ): + # A subclass without a constructor generates a message by default + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionDefault( + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass with a constructor that does not parse `args` to the super class + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructor( + "custom arg", + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert ( + str(exc.value) + == "Parent class _SubExceptionConstructor is missing __str__ method" + ) + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructor( + "custom arg", + ) + assert ( + str(exc.value) + == "Parent class _SubExceptionConstructor is missing __str__ method" + ) + + # A subclass with a constructor that generates the message + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionConstructorGenerate( + "custom arg", + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass without overridden constructors and passed args + # defaults to the passed args + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionDefault( + ValueError("wrong value"), + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "wrong value" + + # A subclass without overridden constructors and passed args + # and generate_message = True, generates a message + with pytest.raises(HomeAssistantError) as exc: + raise _SubExceptionGenerate( + ValueError("wrong value"), + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" + + # A subclass with and ExceptionGroup subclass requires a message to be passed. + # As we pass args, we will not generate the message. + # The __str__ constructor defaults to that of the super class. + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroup( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "group message (2 sub-exceptions)" + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroup( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + ) + assert str(exc.value) == "group message (2 sub-exceptions)" + + # A subclass with and ExceptionGroup subclass requires a message to be passed. + # The `generate_message` flag is set.` + # The __str__ constructor will return the generated message. + with pytest.raises(HomeAssistantError) as exc: + raise _SubClassWithExceptionGroupGenerate( + "group message", + [ValueError("wrong value"), TypeError("wrong type")], + translation_domain="test", + translation_key="bla", + translation_placeholders={"bla": "Bla"}, + ) + assert str(exc.value) == "Bla from cache" From 5f5d40ed523d4c0fbe97b062753c5666f334fc6d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 22 Mar 2024 00:56:31 +0100 Subject: [PATCH 11/15] Bump pyenphase to 1.20.0 (#113963) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 9f437ee9945683..7e2d70e914e72f 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.19.2"], + "requirements": ["pyenphase==1.20.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 07dab3fc6e22e1..cbf6a15a5a0694 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1787,7 +1787,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.19.2 +pyenphase==1.20.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c96de54a87b15b..2353e3df349b02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1386,7 +1386,7 @@ pyeconet==0.1.22 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.19.2 +pyenphase==1.20.0 # homeassistant.components.everlights pyeverlights==0.1.0 From b2cab70cc02d3fe1fc4b656799ef66e3d18c3f17 Mon Sep 17 00:00:00 2001 From: Paul Chanvin Date: Fri, 22 Mar 2024 02:26:11 +0100 Subject: [PATCH 12/15] Fix argument name in async_update_ha_state warning message (#113969) Fixed warning message using async_update_ha_state --- homeassistant/helpers/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a2adee9650049f..988ce29ade2bfa 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -957,7 +957,7 @@ async def async_update_ha_state(self, force_refresh: bool = False) -> None: _LOGGER.warning( ( "Entity %s (%s) is using self.async_update_ha_state(), without" - " enabling force_update. Instead it should use" + " enabling force_refresh. Instead it should use" " self.async_write_ha_state(), please %s" ), self.entity_id, From 5b9f40b0f06921db93650002a84c2dec78d61733 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Mar 2024 15:40:15 -1000 Subject: [PATCH 13/15] Pre import mobile app platforms to avoid having to wait on them (#113966) --- homeassistant/components/mobile_app/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 28f66954ca3afe..20a5448f2be753 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -19,7 +19,16 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from . import websocket_api +# Pre-import the platforms so they get loaded when the integration +# is imported as they are almost always going to be loaded and its +# cheaper to import them all at once. +from . import ( # noqa: F401 + binary_sensor as binary_sensor_pre_import, + device_tracker as device_tracker_pre_import, + notify as notify_pre_import, + sensor as sensor_pre_import, + websocket_api, +) from .const import ( ATTR_DEVICE_NAME, ATTR_MANUFACTURER, From 79f2eaaf41f057ef1decfd50fa0192859b078ede Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 22 Mar 2024 02:48:52 +0100 Subject: [PATCH 14/15] Deprecate the panel_iframe integration (#113410) * Deprecate the panel_iframe integration * Address review comments * Customize issue text * Update test --- .../components/panel_iframe/__init__.py | 62 +++++- .../components/panel_iframe/manifest.json | 2 +- .../components/panel_iframe/strings.json | 8 + tests/components/panel_iframe/test_init.py | 180 ++++++++++++------ 4 files changed, 182 insertions(+), 70 deletions(-) create mode 100644 homeassistant/components/panel_iframe/strings.json diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index c51768952ebe77..1b6dfebd6b05b5 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -2,10 +2,13 @@ import voluptuous as vol -from homeassistant.components import frontend +from homeassistant.components import lovelace +from homeassistant.components.lovelace import dashboard from homeassistant.const import CONF_ICON, CONF_URL from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType DOMAIN = "panel_iframe" @@ -37,18 +40,59 @@ extra=vol.ALLOW_EXTRA, ) +STORAGE_KEY = DOMAIN +STORAGE_VERSION_MAJOR = 1 + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the iFrame frontend panels.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.10.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "iframe Panel", + }, + ) + + store: Store[dict[str, bool]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + ) + data = await store.async_load() + if data: + return True + + dashboards_collection: dashboard.DashboardsCollection = hass.data[lovelace.DOMAIN][ + "dashboards_collection" + ] + for url_path, info in config[DOMAIN].items(): - frontend.async_register_built_in_panel( - hass, - "iframe", - info.get(CONF_TITLE), - info.get(CONF_ICON), - url_path, - {"url": info[CONF_URL]}, - require_admin=info[CONF_REQUIRE_ADMIN], + dashboard_create_data = { + lovelace.CONF_ALLOW_SINGLE_WORD: True, + lovelace.CONF_URL_PATH: url_path, + } + for key in (CONF_ICON, CONF_REQUIRE_ADMIN, CONF_TITLE): + if key in info: + dashboard_create_data[key] = info[key] + + await dashboards_collection.async_create_item(dashboard_create_data) + + dashboard_store: dashboard.LovelaceStorage = hass.data[lovelace.DOMAIN][ + "dashboards" + ][url_path] + await dashboard_store.async_save( + {"strategy": {"type": "iframe", "url": info[CONF_URL]}} ) + await store.async_save({"migrated": True}) + return True diff --git a/homeassistant/components/panel_iframe/manifest.json b/homeassistant/components/panel_iframe/manifest.json index 04eeb93ffa6a35..7a39e0ba17dc3e 100644 --- a/homeassistant/components/panel_iframe/manifest.json +++ b/homeassistant/components/panel_iframe/manifest.json @@ -2,7 +2,7 @@ "domain": "panel_iframe", "name": "iframe Panel", "codeowners": ["@home-assistant/frontend"], - "dependencies": ["frontend"], + "dependencies": ["frontend", "lovelace"], "documentation": "https://www.home-assistant.io/integrations/panel_iframe", "quality_scale": "internal" } diff --git a/homeassistant/components/panel_iframe/strings.json b/homeassistant/components/panel_iframe/strings.json new file mode 100644 index 00000000000000..595b1f04818c78 --- /dev/null +++ b/homeassistant/components/panel_iframe/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically as a regular dashboard.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/tests/components/panel_iframe/test_init.py b/tests/components/panel_iframe/test_init.py index a16d87f1838953..0e898fd626602f 100644 --- a/tests/components/panel_iframe/test_init.py +++ b/tests/components/panel_iframe/test_init.py @@ -1,11 +1,37 @@ """The tests for the panel_iframe component.""" +from typing import Any + import pytest -from homeassistant.components import frontend +from homeassistant.components.panel_iframe import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator + +TEST_CONFIG = { + "router": { + "icon": "mdi:network-wireless", + "title": "Router", + "url": "http://192.168.1.1", + "require_admin": True, + }, + "weather": { + "icon": "mdi:weather", + "title": "Weather", + "url": "https://www.wunderground.com/us/ca/san-diego", + "require_admin": True, + }, + "api": {"icon": "mdi:weather", "title": "Api", "url": "/api"}, + "ftp": { + "icon": "mdi:weather", + "title": "FTP", + "url": "ftp://some/ftp", + }, +} + @pytest.mark.parametrize( "config_to_try", @@ -21,73 +47,107 @@ async def test_wrong_config(hass: HomeAssistant, config_to_try) -> None: ) -async def test_correct_config(hass: HomeAssistant) -> None: - """Test correct config.""" +async def test_import_config( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test import config.""" + client = await hass_ws_client(hass) + assert await async_setup_component( hass, "panel_iframe", + {"panel_iframe": TEST_CONFIG}, + ) + + # List dashboards + await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ { - "panel_iframe": { - "router": { - "icon": "mdi:network-wireless", - "title": "Router", - "url": "http://192.168.1.1", - "require_admin": True, - }, - "weather": { - "icon": "mdi:weather", - "title": "Weather", - "url": "https://www.wunderground.com/us/ca/san-diego", - "require_admin": True, - }, - "api": {"icon": "mdi:weather", "title": "Api", "url": "/api"}, - "ftp": { - "icon": "mdi:weather", - "title": "FTP", - "url": "ftp://some/ftp", - }, - } + "icon": "mdi:network-wireless", + "id": "router", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Router", + "url_path": "router", }, - ) + { + "icon": "mdi:weather", + "id": "weather", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Weather", + "url_path": "weather", + }, + { + "icon": "mdi:weather", + "id": "api", + "mode": "storage", + "require_admin": False, + "show_in_sidebar": True, + "title": "Api", + "url_path": "api", + }, + { + "icon": "mdi:weather", + "id": "ftp", + "mode": "storage", + "require_admin": False, + "show_in_sidebar": True, + "title": "FTP", + "url_path": "ftp", + }, + ] - panels = hass.data[frontend.DATA_PANELS] + for url_path in ["api", "ftp", "router", "weather"]: + await client.send_json_auto_id( + {"type": "lovelace/config", "url_path": url_path} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "strategy": {"type": "iframe", "url": TEST_CONFIG[url_path]["url"]} + } - assert panels.get("router").to_response() == { - "component_name": "iframe", - "config": {"url": "http://192.168.1.1"}, - "config_panel_domain": None, - "icon": "mdi:network-wireless", - "title": "Router", - "url_path": "router", - "require_admin": True, - } + assert hass_storage[DOMAIN]["data"] == {"migrated": True} - assert panels.get("weather").to_response() == { - "component_name": "iframe", - "config": {"url": "https://www.wunderground.com/us/ca/san-diego"}, - "config_panel_domain": None, - "icon": "mdi:weather", - "title": "Weather", - "url_path": "weather", - "require_admin": True, - } - assert panels.get("api").to_response() == { - "component_name": "iframe", - "config": {"url": "/api"}, - "config_panel_domain": None, - "icon": "mdi:weather", - "title": "Api", - "url_path": "api", - "require_admin": False, - } +async def test_import_config_once( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test import config only happens once.""" + client = await hass_ws_client(hass) - assert panels.get("ftp").to_response() == { - "component_name": "iframe", - "config": {"url": "ftp://some/ftp"}, - "config_panel_domain": None, - "icon": "mdi:weather", - "title": "FTP", - "url_path": "ftp", - "require_admin": False, + hass_storage[DOMAIN] = { + "version": 1, + "minor_version": 1, + "key": "map", + "data": {"migrated": True}, } + + assert await async_setup_component( + hass, + "panel_iframe", + {"panel_iframe": TEST_CONFIG}, + ) + + # List dashboards + await client.send_json_auto_id({"type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + + +async def test_create_issue_when_manually_configured(hass: HomeAssistant) -> None: + """Test creating issue registry issues.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") From abb217086f75599ccbb75e652ece0715dd3771d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Mar 2024 15:59:50 -1000 Subject: [PATCH 15/15] Group wemo platform forwards to reduce overhead (#113972) --- homeassistant/components/wemo/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 5cf0566278c620..8a9a122c03cf60 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -193,6 +193,7 @@ async def async_add_unique_device( platforms = set(WEMO_MODEL_DISPATCH.get(wemo.model_name, [Platform.SWITCH])) platforms.add(Platform.SENSOR) + platforms_to_load: list[Platform] = [] for platform in platforms: # Three cases: # - Platform is loaded, dispatch discovery @@ -205,11 +206,14 @@ async def async_add_unique_device( self._dispatch_backlog[platform].append(coordinator) else: self._dispatch_backlog[platform] = [coordinator] - hass.async_create_task( - hass.config_entries.async_forward_entry_setup( - self._config_entry, platform - ) + platforms_to_load.append(platform) + + if platforms_to_load: + hass.async_create_task( + hass.config_entries.async_forward_entry_setups( + self._config_entry, platforms_to_load ) + ) self._added_serial_numbers.add(wemo.serial_number) self._failed_serial_numbers.discard(wemo.serial_number)