diff --git a/.strict-typing b/.strict-typing index 1681c37e2e813a..30facd369b50c3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -166,6 +166,7 @@ homeassistant.components.electric_kiwi.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* +homeassistant.components.energenie_power_sockets.* homeassistant.components.energy.* homeassistant.components.energyzero.* homeassistant.components.enigma2.* diff --git a/CODEOWNERS b/CODEOWNERS index a3ff3faba72c46..a51efff2ae2ff2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -378,6 +378,8 @@ build.json @home-assistant/supervisor /tests/components/emulated_hue/ @bdraco @Tho85 /homeassistant/components/emulated_kasa/ @kbickar /tests/components/emulated_kasa/ @kbickar +/homeassistant/components/energenie_power_sockets/ @gnumpi +/tests/components/energenie_power_sockets/ @gnumpi /homeassistant/components/energy/ @home-assistant/core /tests/components/energy/ @home-assistant/core /homeassistant/components/energyzero/ @klaasnicolaas diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index bf805b5ef21c16..a2c187fc537d66 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -1,9 +1,36 @@ """Block blocking calls being done in asyncio.""" +from contextlib import suppress from http.client import HTTPConnection +import importlib +import sys import time +from typing import Any -from .util.async_ import protect_loop +from .helpers.frame import get_current_frame +from .util.loop import protect_loop + +_IN_TESTS = "unittest" in sys.modules + + +def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool: + # If the module is already imported, we can ignore it. + return bool((args := mapped_args.get("args")) and args[0] in sys.modules) + + +def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: + # + # Avoid extracting the stack unless we need to since it + # will have to access the linecache which can do blocking + # I/O and we are trying to avoid blocking calls. + # + # frame[0] is us + # frame[1] is check_loop + # frame[2] is protected_loop_func + # frame[3] is the offender + with suppress(ValueError): + return get_current_frame(4).f_code.co_filename.endswith("pydevd.py") + return False def enable() -> None: @@ -14,8 +41,20 @@ def enable() -> None: ) # Prevent sleeping in event loop. Non-strict since 2022.02 - time.sleep = protect_loop(time.sleep, strict=False) + time.sleep = protect_loop( + time.sleep, strict=False, check_allowed=_check_sleep_call_allowed + ) # Currently disabled. pytz doing I/O when getting timezone. # Prevent files being opened inside the event loop # builtins.open = protect_loop(builtins.open) + + if not _IN_TESTS: + # unittest uses `importlib.import_module` to do mocking + # so we cannot protect it if we are running tests + importlib.import_module = protect_loop( + importlib.import_module, + strict_core=False, + strict=False, + check_allowed=_check_import_call_allowed, + ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 03c0de1ff62246..97bdd615d69aee 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -23,7 +23,14 @@ import voluptuous as vol import yarl -from . import config as conf_util, config_entries, core, loader, requirements +from . import ( + block_async_io, + config as conf_util, + config_entries, + core, + loader, + requirements, +) # Pre-import frontend deps which have no requirements here to avoid # loading them at run time and blocking the event loop. We do this ahead @@ -93,6 +100,11 @@ from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_virtual_env +with contextlib.suppress(ImportError): + # Ensure anyio backend is imported to avoid it being imported in the event loop + from anyio._backends import _asyncio # noqa: F401 + + if TYPE_CHECKING: from .runner import RuntimeConfig @@ -255,6 +267,8 @@ async def async_setup_hass( _LOGGER.info("Config directory: %s", runtime_config.config_dir) loader.async_setup(hass) + block_async_io.enable() + config_dict = None basic_setup_success = False diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index 174e8716e0b6d8..a94700c27d16b1 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -60,10 +60,6 @@ def _fetch_sites(self, token: str) -> list[Site] | None: try: sites: list[Site] = filter_sites(api.get_sites()) - if len(sites) == 0: - self._errors[CONF_API_TOKEN] = "no_site" - return None - return sites except amberelectric.ApiException as api_exception: if api_exception.status == 403: self._errors[CONF_API_TOKEN] = "invalid_api_token" @@ -71,6 +67,11 @@ def _fetch_sites(self, token: str) -> list[Site] | None: self._errors[CONF_API_TOKEN] = "unknown_error" return None + if len(sites) == 0: + self._errors[CONF_API_TOKEN] = "no_site" + return None + return sites + async def async_step_user( self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 65c3930e97d4c2..79556fb68c2caf 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -49,9 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnalyticsInsightsData( - coordinator=coordinator, names=names - ) + hass.data[DOMAIN] = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -62,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + hass.data.pop(DOMAIN) return unload_ok diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index 30b8ca12579f83..cef5ac2e9e57e3 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -53,7 +53,6 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - self._async_abort_entries_match() errors: dict[str, str] = {} if user_input is not None: if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index d33bb23b1b78b6..adf2d634ef8721 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.6.0"] + "requirements": ["python-homeassistant-analytics==0.6.0"], + "single_config_entry": true } diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index e776ddb9f41073..ee1496eb52c619 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( ) -> None: """Initialize the entries.""" - analytics_data: AnalyticsInsightsData = hass.data[DOMAIN][entry.entry_id] + analytics_data: AnalyticsInsightsData = hass.data[DOMAIN] coordinator: HomeassistantAnalyticsDataUpdateCoordinator = ( analytics_data.coordinator ) diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 6de1ab9dbe4988..00c9cfa44043e7 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -13,8 +13,7 @@ } }, "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "error": { "no_integration_selected": "You must select at least one integration to track" diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 19cbb24d8a2a91..f9be827741b31f 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -76,9 +76,9 @@ def _host_filter() -> list[str] | None: return None try: ip_address(identifier) - return [identifier] except ValueError: return None + return [identifier] # If we have an address, only probe that address to avoid # broadcast traffic on the network diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 232f067b673a86..45de94d5a701a5 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -127,9 +127,9 @@ def verify_client_id(client_id: str) -> bool: """Verify that the client id is valid.""" try: _parse_client_id(client_id) - return True except ValueError: return False + return True def _parse_url(url: str) -> ParseResult: diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index d3c4703e89cd3c..a6efc3640f93b1 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -250,13 +250,12 @@ async def _check_cloud_connection( try: user = await awair.user() devices = await user.devices() - if not devices: - return (None, "no_devices_found") - - return (user, None) - except AuthError: return (None, "invalid_access_token") except AwairError as err: LOGGER.error("Unexpected API error: %s", err) return (None, "unknown") + + if not devices: + return (None, "no_devices_found") + return (user, None) diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py index 8e554b3b9e067d..b63efff77336cf 100644 --- a/homeassistant/components/awair/coordinator.py +++ b/homeassistant/components/awair/coordinator.py @@ -111,7 +111,7 @@ async def _async_update_data(self) -> dict[str, AwairResult]: devices = await self._awair.devices() self._device = devices[0] result = await self._fetch_air_data(self._device) - return {result.device.uuid: result} except AwairError as err: LOGGER.error("Unexpected API error: %s", err) raise UpdateFailed(err) from err + return {result.device.uuid: result} diff --git a/homeassistant/components/axis/hub/hub.py b/homeassistant/components/axis/hub/hub.py index 08eb816f6abe42..4abd13584179ed 100644 --- a/homeassistant/components/axis/hub/hub.py +++ b/homeassistant/components/axis/hub/hub.py @@ -116,7 +116,7 @@ async def async_use_mqtt(self, hass: HomeAssistant, component: str) -> None: if status.status.state == ClientState.ACTIVE: self.config.entry.async_on_unload( await mqtt.async_subscribe( - hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message + hass, f"{status.config.device_topic_prefix}/#", self.mqtt_message ) ) @@ -124,7 +124,8 @@ async def async_use_mqtt(self, hass: HomeAssistant, component: str) -> None: def mqtt_message(self, message: ReceiveMessage) -> None: """Receive Axis MQTT message.""" self.disconnect_from_stream() - + if message.topic.endswith("event/connection"): + return event = mqtt_json_to_event(message.payload) self.api.event.handler(event) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index f47d10df4846a7..1065783d9576d5 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==59"], + "requirements": ["axis==60"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index fb15aa49001f0b..69c762c1bc1925 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -819,22 +819,23 @@ def _load_data(self, data): # noqa: C901 self._attr_native_value = data.get(FORECAST)[fcday].get( sensor_type[:-3] ) - if self.state is not None: - self._attr_native_value = round(self.state * 3.6, 1) - return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) return False + if self.state is not None: + self._attr_native_value = round(self.state * 3.6, 1) + return True + # update all other sensors try: self._attr_native_value = data.get(FORECAST)[fcday].get( sensor_type[:-3] ) - return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) return False + return True if sensor_type == SYMBOL or sensor_type.startswith(CONDITION): # update weather symbol & status text diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index 60863523553496..8f937ef61ea71c 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -43,7 +43,6 @@ async def _test_connection( user_input[CONF_HOST], user_input.get(CONF_PORT, DEFAULT_PORT), ) - return True except ResolveFailed: self._errors[CONF_HOST] = "resolve_failed" except ConnectionTimeout: @@ -52,6 +51,8 @@ async def _test_connection( self._errors[CONF_HOST] = "connection_refused" except ValidationFailure: return True + else: + return True return False async def async_step_user( diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 0cf27c20fa64f1..de85e6309f9b8d 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -228,6 +228,9 @@ async def get_closest_network_id(self, latitude, longitude): self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA ) self.networks = networks[ATTR_NETWORKS_LIST] + except CityBikesRequestError as err: + raise PlatformNotReady from err + else: result = None minimum_dist = None for network in self.networks: @@ -241,8 +244,6 @@ async def get_closest_network_id(self, latitude, longitude): result = network[ATTR_ID] return result - except CityBikesRequestError as err: - raise PlatformNotReady from err finally: self.networks_loading.release() diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index db263451f0b784..3073d3e3c267e9 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -58,6 +58,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse raise intent.NoStatesMatchedError( name=entity_text or entity_name, area=area_name or area_id, + floor=None, domains={DOMAIN}, device_classes=None, ) @@ -75,6 +76,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse raise intent.NoStatesMatchedError( name=entity_name, area=None, + floor=None, domains={DOMAIN}, device_classes=None, ) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 12f2b04d856ba4..467d1589398187 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -509,16 +509,13 @@ async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool try: async with asyncio.timeout(10): await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) - - return True - except TimeoutError: _LOGGER.warning("Timeout trying to sync entities to Alexa") return False - except aiohttp.ClientError as err: _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) return False + return True async def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 1a8fd7dbea9038..8ca55876b2886a 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -135,12 +135,12 @@ async def error_handler( """Handle exceptions that raise from the wrapped request handler.""" try: result = await handler(view, request, *args, **kwargs) - return result except Exception as err: # pylint: disable=broad-except status, msg = _process_cloud_exception(err, request.path) return view.json_message( msg, status_code=status, message_code=err.__class__.__name__.lower() ) + return result return error_handler diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 067efc08e975eb..c1926546950378 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -25,20 +25,21 @@ async def async_call_shell_with_timeout( ) async with asyncio.timeout(timeout): await proc.communicate() - return_code = proc.returncode - if return_code == _EXEC_FAILED_CODE: - _LOGGER.error("Error trying to exec command: %s", command) - elif log_return_code and return_code != 0: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc.returncode, - command, - ) - return return_code or 0 except TimeoutError: _LOGGER.error("Timeout for command: %s", command) return -1 + return_code = proc.returncode + if return_code == _EXEC_FAILED_CODE: + _LOGGER.error("Error trying to exec command: %s", command) + elif log_return_code and return_code != 0: + _LOGGER.error( + "Command failed (with return code %s): %s", + proc.returncode, + command, + ) + return return_code or 0 + async def async_check_output_or_log(command: str, timeout: int) -> str | None: """Run a shell command with a timeout and return the output.""" diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 2d7c6ade255f6b..4ecc1ebe3f5954 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -68,9 +68,9 @@ async def authenticate(self) -> bool: self.director_bearer_token = ( await account.getDirectorBearerToken(self.controller_unique_id) )["token"] - return True except (Unauthorized, NotFound): return False + return True async def connect_to_director(self) -> bool: """Test if we can connect to the local Control4 Director.""" @@ -82,10 +82,10 @@ async def connect_to_director(self) -> bool: self.host, self.director_bearer_token, director_session ) await director.getAllItemInfo() - return True except (Unauthorized, ClientError, TimeoutError): _LOGGER.error("Failed to connect to the Control4 controller") return False + return True class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index dd8fb967824e44..a0717ddaa58d3b 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -2,43 +2,36 @@ from __future__ import annotations -import asyncio from collections.abc import Iterable -from dataclasses import dataclass import logging import re -from typing import Any, Literal - -from aiohttp import web -from hassil.recognize import ( - MISSING_ENTITY, - RecognizeResult, - UnmatchedRangeEntity, - UnmatchedTextEntity, -) +from typing import Literal + import voluptuous as vol -from homeassistant import core -from homeassistant.components import http, websocket_api -from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, intent, singleton +from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import language as language_util - -from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import HOME_ASSISTANT_AGENT -from .default_agent import ( - METADATA_CUSTOM_FILE, - METADATA_CUSTOM_SENTENCE, - DefaultAgent, - SentenceTriggerResult, - async_setup as async_setup_default_agent, + +from .agent_manager import ( + AgentInfo, + agent_id_validator, + async_converse, + get_agent_manager, ) +from .const import DATA_CONFIG, HOME_ASSISTANT_AGENT +from .http import async_setup as async_setup_conversation_http +from .models import AbstractConversationAgent, ConversationInput, ConversationResult __all__ = [ "DOMAIN", @@ -48,6 +41,8 @@ "async_set_agent", "async_unset_agent", "async_setup", + "ConversationInput", + "ConversationResult", ] _LOGGER = logging.getLogger(__name__) @@ -60,21 +55,11 @@ DOMAIN = "conversation" REGEX_TYPE = type(re.compile("")) -DATA_CONFIG = "conversation_config" SERVICE_PROCESS = "process" SERVICE_RELOAD = "reload" -def agent_id_validator(value: Any) -> str: - """Validate agent ID.""" - hass = core.async_get_hass() - manager = _get_agent_manager(hass) - if not manager.async_is_valid_agent_id(cv.string(value)): - raise vol.Invalid("invalid agent ID") - return value - - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, @@ -106,34 +91,25 @@ def agent_id_validator(value: Any) -> str: ) -@singleton.singleton("conversation_agent") -@core.callback -def _get_agent_manager(hass: HomeAssistant) -> AgentManager: - """Get the active agent.""" - manager = AgentManager(hass) - manager.async_setup() - return manager - - -@core.callback +@callback @bind_hass def async_set_agent( - hass: core.HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntry, agent: AbstractConversationAgent, ) -> None: """Set the agent to handle the conversations.""" - _get_agent_manager(hass).async_set_agent(config_entry.entry_id, agent) + get_agent_manager(hass).async_set_agent(config_entry.entry_id, agent) -@core.callback +@callback @bind_hass def async_unset_agent( - hass: core.HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntry, ) -> None: """Set the agent to handle the conversations.""" - _get_agent_manager(hass).async_unset_agent(config_entry.entry_id) + get_agent_manager(hass).async_unset_agent(config_entry.entry_id) async def async_get_conversation_languages( @@ -145,7 +121,7 @@ async def async_get_conversation_languages( If no agent is specified, return a set with the union of languages supported by all conversation agents. """ - agent_manager = _get_agent_manager(hass) + agent_manager = get_agent_manager(hass) languages: set[str] = set() agent_ids: Iterable[str] @@ -164,14 +140,32 @@ async def async_get_conversation_languages( return languages +@callback +def async_get_agent_info( + hass: HomeAssistant, + agent_id: str | None = None, +) -> AgentInfo | None: + """Get information on the agent or None if not found.""" + manager = get_agent_manager(hass) + + if agent_id is None: + agent_id = manager.default_agent + + for agent_info in manager.async_get_agent_info(): + if agent_info.id == agent_id: + return agent_info + + return None + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" - agent_manager = _get_agent_manager(hass) + agent_manager = get_agent_manager(hass) if config_intents := config.get(DOMAIN, {}).get("intents"): hass.data[DATA_CONFIG] = config_intents - async def handle_process(service: core.ServiceCall) -> core.ServiceResponse: + async def handle_process(service: ServiceCall) -> ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] _LOGGER.debug("Processing: <%s>", text) @@ -192,7 +186,7 @@ async def handle_process(service: core.ServiceCall) -> core.ServiceResponse: return None - async def handle_reload(service: core.ServiceCall) -> None: + async def handle_reload(service: ServiceCall) -> None: """Reload intents.""" agent = await agent_manager.async_get_agent() await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) @@ -202,440 +196,11 @@ async def handle_reload(service: core.ServiceCall) -> None: SERVICE_PROCESS, handle_process, schema=SERVICE_PROCESS_SCHEMA, - supports_response=core.SupportsResponse.OPTIONAL, + supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_RELOAD, handle_reload, schema=SERVICE_RELOAD_SCHEMA ) - hass.http.register_view(ConversationProcessView()) - websocket_api.async_register_command(hass, websocket_process) - websocket_api.async_register_command(hass, websocket_prepare) - websocket_api.async_register_command(hass, websocket_list_agents) - websocket_api.async_register_command(hass, websocket_hass_agent_debug) + async_setup_conversation_http(hass) return True - - -@websocket_api.websocket_command( - { - vol.Required("type"): "conversation/process", - vol.Required("text"): str, - vol.Optional("conversation_id"): vol.Any(str, None), - vol.Optional("language"): str, - vol.Optional("agent_id"): agent_id_validator, - } -) -@websocket_api.async_response -async def websocket_process( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Process text.""" - result = await async_converse( - hass=hass, - text=msg["text"], - conversation_id=msg.get("conversation_id"), - context=connection.context(msg), - language=msg.get("language"), - agent_id=msg.get("agent_id"), - ) - connection.send_result(msg["id"], result.as_dict()) - - -@websocket_api.websocket_command( - { - "type": "conversation/prepare", - vol.Optional("language"): str, - vol.Optional("agent_id"): agent_id_validator, - } -) -@websocket_api.async_response -async def websocket_prepare( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Reload intents.""" - manager = _get_agent_manager(hass) - agent = await manager.async_get_agent(msg.get("agent_id")) - await agent.async_prepare(msg.get("language")) - connection.send_result(msg["id"]) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "conversation/agent/list", - vol.Optional("language"): str, - vol.Optional("country"): str, - } -) -@websocket_api.async_response -async def websocket_list_agents( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """List conversation agents and, optionally, if they support a given language.""" - manager = _get_agent_manager(hass) - - country = msg.get("country") - language = msg.get("language") - agents = [] - - for agent_info in manager.async_get_agent_info(): - agent = await manager.async_get_agent(agent_info.id) - - supported_languages = agent.supported_languages - if language and supported_languages != MATCH_ALL: - supported_languages = language_util.matches( - language, supported_languages, country - ) - - agent_dict: dict[str, Any] = { - "id": agent_info.id, - "name": agent_info.name, - "supported_languages": supported_languages, - } - agents.append(agent_dict) - - connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "conversation/agent/homeassistant/debug", - vol.Required("sentences"): [str], - vol.Optional("language"): str, - vol.Optional("device_id"): vol.Any(str, None), - } -) -@websocket_api.async_response -async def websocket_hass_agent_debug( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """Return intents that would be matched by the default agent for a list of sentences.""" - agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) - assert isinstance(agent, DefaultAgent) - results = [ - await agent.async_recognize( - ConversationInput( - text=sentence, - context=connection.context(msg), - conversation_id=None, - device_id=msg.get("device_id"), - language=msg.get("language", hass.config.language), - ) - ) - for sentence in msg["sentences"] - ] - - # Return results for each sentence in the same order as the input. - result_dicts: list[dict[str, Any] | None] = [] - for result in results: - result_dict: dict[str, Any] | None = None - if isinstance(result, SentenceTriggerResult): - result_dict = { - # Matched a user-defined sentence trigger. - # We can't provide the response here without executing the - # trigger. - "match": True, - "source": "trigger", - "sentence_template": result.sentence_template or "", - } - elif isinstance(result, RecognizeResult): - successful_match = not result.unmatched_entities - result_dict = { - # Name of the matching intent (or the closest) - "intent": { - "name": result.intent.name, - }, - # Slot values that would be received by the intent - "slots": { # direct access to values - entity_key: entity.text or entity.value - for entity_key, entity in result.entities.items() - }, - # Extra slot details, such as the originally matched text - "details": { - entity_key: { - "name": entity.name, - "value": entity.value, - "text": entity.text, - } - for entity_key, entity in result.entities.items() - }, - # Entities/areas/etc. that would be targeted - "targets": {}, - # True if match was successful - "match": successful_match, - # Text of the sentence template that matched (or was closest) - "sentence_template": "", - # When match is incomplete, this will contain the best slot guesses - "unmatched_slots": _get_unmatched_slots(result), - } - - if successful_match: - result_dict["targets"] = { - state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, result) - } - - if result.intent_sentence is not None: - result_dict["sentence_template"] = result.intent_sentence.text - - # Inspect metadata to determine if this matched a custom sentence - if result.intent_metadata and result.intent_metadata.get( - METADATA_CUSTOM_SENTENCE - ): - result_dict["source"] = "custom" - result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE) - else: - result_dict["source"] = "builtin" - - result_dicts.append(result_dict) - - connection.send_result(msg["id"], {"results": result_dicts}) - - -def _get_debug_targets( - hass: HomeAssistant, - result: RecognizeResult, -) -> Iterable[tuple[core.State, bool]]: - """Yield state/is_matched pairs for a hassil recognition.""" - entities = result.entities - - name: str | None = None - area_name: str | None = None - domains: set[str] | None = None - device_classes: set[str] | None = None - state_names: set[str] | None = None - - if "name" in entities: - name = str(entities["name"].value) - - if "area" in entities: - area_name = str(entities["area"].value) - - if "domain" in entities: - domains = set(cv.ensure_list(entities["domain"].value)) - - if "device_class" in entities: - device_classes = set(cv.ensure_list(entities["device_class"].value)) - - if "state" in entities: - # HassGetState only - state_names = set(cv.ensure_list(entities["state"].value)) - - if ( - (name is None) - and (area_name is None) - and (not domains) - and (not device_classes) - and (not state_names) - ): - # Avoid "matching" all entities when there is no filter - return - - states = intent.async_match_states( - hass, - name=name, - area_name=area_name, - domains=domains, - device_classes=device_classes, - ) - - for state in states: - # For queries, a target is "matched" based on its state - is_matched = (state_names is None) or (state.state in state_names) - yield state, is_matched - - -def _get_unmatched_slots( - result: RecognizeResult, -) -> dict[str, str | int]: - """Return a dict of unmatched text/range slot entities.""" - unmatched_slots: dict[str, str | int] = {} - for entity in result.unmatched_entities_list: - if isinstance(entity, UnmatchedTextEntity): - if entity.text == MISSING_ENTITY: - # Don't report since these are just missing context - # slots. - continue - - unmatched_slots[entity.name] = entity.text - elif isinstance(entity, UnmatchedRangeEntity): - unmatched_slots[entity.name] = entity.value - - return unmatched_slots - - -class ConversationProcessView(http.HomeAssistantView): - """View to process text.""" - - url = "/api/conversation/process" - name = "api:conversation:process" - - @RequestDataValidator( - vol.Schema( - { - vol.Required("text"): str, - vol.Optional("conversation_id"): str, - vol.Optional("language"): str, - vol.Optional("agent_id"): agent_id_validator, - } - ) - ) - async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: - """Send a request for processing.""" - hass = request.app[http.KEY_HASS] - - result = await async_converse( - hass, - text=data["text"], - conversation_id=data.get("conversation_id"), - context=self.context(request), - language=data.get("language"), - agent_id=data.get("agent_id"), - ) - - return self.json(result.as_dict()) - - -@dataclass(frozen=True) -class AgentInfo: - """Container for conversation agent info.""" - - id: str - name: str - - -@core.callback -def async_get_agent_info( - hass: core.HomeAssistant, - agent_id: str | None = None, -) -> AgentInfo | None: - """Get information on the agent or None if not found.""" - manager = _get_agent_manager(hass) - - if agent_id is None: - agent_id = manager.default_agent - - for agent_info in manager.async_get_agent_info(): - if agent_info.id == agent_id: - return agent_info - - return None - - -async def async_converse( - hass: core.HomeAssistant, - text: str, - conversation_id: str | None, - context: core.Context, - language: str | None = None, - agent_id: str | None = None, - device_id: str | None = None, -) -> ConversationResult: - """Process text and get intent.""" - agent = await _get_agent_manager(hass).async_get_agent(agent_id) - - if language is None: - language = hass.config.language - - _LOGGER.debug("Processing in %s: %s", language, text) - result = await agent.async_process( - ConversationInput( - text=text, - context=context, - conversation_id=conversation_id, - device_id=device_id, - language=language, - ) - ) - return result - - -class AgentManager: - """Class to manage conversation agents.""" - - default_agent: str = HOME_ASSISTANT_AGENT - _builtin_agent: AbstractConversationAgent | None = None - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the conversation agents.""" - self.hass = hass - self._agents: dict[str, AbstractConversationAgent] = {} - self._builtin_agent_init_lock = asyncio.Lock() - - def async_setup(self) -> None: - """Set up the conversation agents.""" - async_setup_default_agent(self.hass) - - async def async_get_agent( - self, agent_id: str | None = None - ) -> AbstractConversationAgent: - """Get the agent.""" - if agent_id is None: - agent_id = self.default_agent - - if agent_id == HOME_ASSISTANT_AGENT: - if self._builtin_agent is not None: - return self._builtin_agent - - async with self._builtin_agent_init_lock: - if self._builtin_agent is not None: - return self._builtin_agent - - self._builtin_agent = DefaultAgent(self.hass) - await self._builtin_agent.async_initialize( - self.hass.data.get(DATA_CONFIG) - ) - - return self._builtin_agent - - if agent_id not in self._agents: - raise ValueError(f"Agent {agent_id} not found") - - return self._agents[agent_id] - - @core.callback - def async_get_agent_info(self) -> list[AgentInfo]: - """List all agents.""" - agents: list[AgentInfo] = [ - AgentInfo( - id=HOME_ASSISTANT_AGENT, - name="Home Assistant", - ) - ] - for agent_id, agent in self._agents.items(): - config_entry = self.hass.config_entries.async_get_entry(agent_id) - - # Guard against potential bugs in conversation agents where the agent is not - # removed from the manager when the config entry is removed - if config_entry is None: - _LOGGER.warning( - "Conversation agent %s is still loaded after config entry removal", - agent, - ) - continue - - agents.append( - AgentInfo( - id=agent_id, - name=config_entry.title or config_entry.domain, - ) - ) - return agents - - @core.callback - def async_is_valid_agent_id(self, agent_id: str) -> bool: - """Check if the agent id is valid.""" - return agent_id in self._agents or agent_id == HOME_ASSISTANT_AGENT - - @core.callback - def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: - """Set the agent.""" - self._agents[agent_id] = agent - - @core.callback - def async_unset_agent(self, agent_id: str) -> None: - """Unset the agent.""" - self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py new file mode 100644 index 00000000000000..f34ecfaecc9509 --- /dev/null +++ b/homeassistant/components/conversation/agent_manager.py @@ -0,0 +1,161 @@ +"""Agent foundation for conversation integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.core import Context, HomeAssistant, async_get_hass, callback +from homeassistant.helpers import config_validation as cv, singleton + +from .const import DATA_CONFIG, HOME_ASSISTANT_AGENT +from .default_agent import DefaultAgent, async_setup as async_setup_default_agent +from .models import AbstractConversationAgent, ConversationInput, ConversationResult + +_LOGGER = logging.getLogger(__name__) + + +@singleton.singleton("conversation_agent") +@callback +def get_agent_manager(hass: HomeAssistant) -> AgentManager: + """Get the active agent.""" + manager = AgentManager(hass) + manager.async_setup() + return manager + + +def agent_id_validator(value: Any) -> str: + """Validate agent ID.""" + hass = async_get_hass() + manager = get_agent_manager(hass) + if not manager.async_is_valid_agent_id(cv.string(value)): + raise vol.Invalid("invalid agent ID") + return value + + +async def async_converse( + hass: HomeAssistant, + text: str, + conversation_id: str | None, + context: Context, + language: str | None = None, + agent_id: str | None = None, + device_id: str | None = None, +) -> ConversationResult: + """Process text and get intent.""" + agent = await get_agent_manager(hass).async_get_agent(agent_id) + + if language is None: + language = hass.config.language + + _LOGGER.debug("Processing in %s: %s", language, text) + result = await agent.async_process( + ConversationInput( + text=text, + context=context, + conversation_id=conversation_id, + device_id=device_id, + language=language, + ) + ) + return result + + +@dataclass(frozen=True) +class AgentInfo: + """Container for conversation agent info.""" + + id: str + name: str + + +class AgentManager: + """Class to manage conversation agents.""" + + default_agent: str = HOME_ASSISTANT_AGENT + _builtin_agent: AbstractConversationAgent | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the conversation agents.""" + self.hass = hass + self._agents: dict[str, AbstractConversationAgent] = {} + self._builtin_agent_init_lock = asyncio.Lock() + + def async_setup(self) -> None: + """Set up the conversation agents.""" + async_setup_default_agent(self.hass) + + async def async_get_agent( + self, agent_id: str | None = None + ) -> AbstractConversationAgent: + """Get the agent.""" + if agent_id is None: + agent_id = self.default_agent + + if agent_id == HOME_ASSISTANT_AGENT: + if self._builtin_agent is not None: + return self._builtin_agent + + async with self._builtin_agent_init_lock: + if self._builtin_agent is not None: + return self._builtin_agent + + self._builtin_agent = DefaultAgent(self.hass) + await self._builtin_agent.async_initialize( + self.hass.data.get(DATA_CONFIG) + ) + + return self._builtin_agent + + if agent_id not in self._agents: + raise ValueError(f"Agent {agent_id} not found") + + return self._agents[agent_id] + + @callback + def async_get_agent_info(self) -> list[AgentInfo]: + """List all agents.""" + agents: list[AgentInfo] = [ + AgentInfo( + id=HOME_ASSISTANT_AGENT, + name="Home Assistant", + ) + ] + for agent_id, agent in self._agents.items(): + config_entry = self.hass.config_entries.async_get_entry(agent_id) + + # Guard against potential bugs in conversation agents where the agent is not + # removed from the manager when the config entry is removed + if config_entry is None: + _LOGGER.warning( + "Conversation agent %s is still loaded after config entry removal", + agent, + ) + continue + + agents.append( + AgentInfo( + id=agent_id, + name=config_entry.title or config_entry.domain, + ) + ) + return agents + + @callback + def async_is_valid_agent_id(self, agent_id: str) -> bool: + """Check if the agent id is valid.""" + return agent_id in self._agents or agent_id == HOME_ASSISTANT_AGENT + + @callback + def async_set_agent(self, agent_id: str, agent: AbstractConversationAgent) -> None: + """Set the agent.""" + self._agents[agent_id] = agent + + @callback + def async_unset_agent(self, agent_id: str) -> None: + """Unset the agent.""" + self._agents.pop(agent_id, None) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index a8828fcc0e9df0..5cb5ca3bdea873 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -3,3 +3,4 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "homeassistant" +DATA_CONFIG = "conversation_config" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 96b0565ebd3321..5a8d7b64eec210 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -34,6 +34,7 @@ area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, intent, start, template, @@ -45,8 +46,8 @@ ) from homeassistant.util.json import JsonObjectType, json_loads_object -from .agent import AbstractConversationAgent, ConversationInput, ConversationResult from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN +from .models import AbstractConversationAgent, ConversationInput, ConversationResult _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -146,6 +147,7 @@ def __init__(self, hass: core.HomeAssistant) -> None: # Sentences that will trigger a callback (skipping intent recognition) self._trigger_sentences: list[TriggerData] = [] self._trigger_intents: Intents | None = None + self._unsub_clear_slot_list: list[Callable[[], None]] | None = None @property def supported_languages(self) -> list[str]: @@ -161,25 +163,49 @@ async def async_initialize(self, config_intents: dict[str, Any] | None) -> None: if config_intents: self._config_intents = config_intents - self.hass.bus.async_listen( - ar.EVENT_AREA_REGISTRY_UPDATED, - self._async_handle_area_registry_changed, - run_immediately=True, - ) - self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - self._async_handle_entity_registry_changed, - run_immediately=True, - ) - self.hass.bus.async_listen( - EVENT_STATE_CHANGED, - self._async_handle_state_changed, - run_immediately=True, - ) - async_listen_entity_updates( - self.hass, DOMAIN, self._async_exposed_entities_updated + @core.callback + def _filter_entity_registry_changes(self, event_data: dict[str, Any]) -> bool: + """Filter entity registry changed events.""" + return event_data["action"] == "update" and any( + field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS ) + @core.callback + def _filter_state_changes(self, event_data: dict[str, Any]) -> bool: + """Filter state changed events.""" + return not event_data["old_state"] or not event_data["new_state"] + + @core.callback + def _listen_clear_slot_list(self) -> None: + """Listen for changes that can invalidate slot list.""" + assert self._unsub_clear_slot_list is None + + self._unsub_clear_slot_list = [ + self.hass.bus.async_listen( + ar.EVENT_AREA_REGISTRY_UPDATED, + self._async_clear_slot_list, + run_immediately=True, + ), + self.hass.bus.async_listen( + fr.EVENT_FLOOR_REGISTRY_UPDATED, + self._async_clear_slot_list, + run_immediately=True, + ), + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._async_clear_slot_list, + event_filter=self._filter_entity_registry_changes, + run_immediately=True, + ), + self.hass.bus.async_listen( + EVENT_STATE_CHANGED, + self._async_clear_slot_list, + event_filter=self._filter_state_changes, + run_immediately=True, + ), + async_listen_entity_updates(self.hass, DOMAIN, self._async_clear_slot_list), + ] + async def async_recognize( self, user_input: ConversationInput ) -> RecognizeResult | SentenceTriggerResult | None: @@ -537,6 +563,9 @@ async def async_prepare(self, language: str | None = None) -> None: if lang_intents is None: # No intents loaded _LOGGER.warning("No intents were loaded for language: %s", language) + return + + self._make_slot_lists() async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" @@ -696,37 +725,17 @@ def _get_or_load_intents( return lang_intents @core.callback - def _async_handle_area_registry_changed( - self, event: core.Event[ar.EventAreaRegistryUpdatedData] - ) -> None: - """Clear area area cache when the area registry has changed.""" - self._slot_lists = None - - @core.callback - def _async_handle_entity_registry_changed( - self, event: core.Event[er.EventEntityRegistryUpdatedData] - ) -> None: - """Clear names list cache when an entity registry entry has changed.""" - if event.data["action"] != "update" or not any( - field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS - ): - return - self._slot_lists = None - - @core.callback - def _async_handle_state_changed( - self, event: core.Event[EventStateChangedData] + def _async_clear_slot_list( + self, event: core.Event[dict[str, Any]] | None = None ) -> None: - """Clear names list cache when a state is added or removed from the state machine.""" - if event.data["old_state"] and event.data["new_state"]: - return + """Clear slot lists when a registry has changed.""" self._slot_lists = None + assert self._unsub_clear_slot_list is not None + for unsub in self._unsub_clear_slot_list: + unsub() + self._unsub_clear_slot_list = None @core.callback - def _async_exposed_entities_updated(self) -> None: - """Handle updated preferences.""" - self._slot_lists = None - def _make_slot_lists(self) -> dict[str, SlotList]: """Create slot lists with areas and entity names/aliases.""" if self._slot_lists is not None: @@ -773,6 +782,8 @@ def _make_slot_lists(self) -> dict[str, SlotList]: # Default name entity_names.append((state.name, state.name, context)) + _LOGGER.debug("Exposed entities: %s", entity_names) + # Expose all areas. # # We pass in area id here with the expectation that no two areas will @@ -788,13 +799,28 @@ def _make_slot_lists(self) -> dict[str, SlotList]: area_names.append((alias, area.id)) - _LOGGER.debug("Exposed entities: %s", entity_names) + # Expose all floors. + # + # We pass in floor id here with the expectation that no two floors will + # share the same name or alias. + floors = fr.async_get(self.hass) + floor_names = [] + for floor in floors.async_list_floors(): + floor_names.append((floor.name, floor.floor_id)) + if floor.aliases: + for alias in floor.aliases: + if not alias.strip(): + continue + + floor_names.append((alias, floor.floor_id)) self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), "name": TextSlotList.from_tuples(entity_names, allow_template=False), + "floor": TextSlotList.from_tuples(floor_names, allow_template=False), } + self._listen_clear_slot_list() return self._slot_lists def _make_intent_context( @@ -953,6 +979,10 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str # area only return ErrorKey.NO_AREA, {"area": unmatched_area} + if unmatched_floor := unmatched_text.get("floor"): + # floor only + return ErrorKey.NO_FLOOR, {"floor": unmatched_floor} + # Area may still have matched matched_area: str | None = None if matched_area_entity := result.entities.get("area"): @@ -1000,6 +1030,13 @@ def _get_no_states_matched_response( "area": no_states_error.area, } + if no_states_error.floor: + # domain in floor + return ErrorKey.NO_DOMAIN_IN_FLOOR, { + "domain": domain, + "floor": no_states_error.floor, + } + # domain only return ErrorKey.NO_DOMAIN, {"domain": domain} diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py new file mode 100644 index 00000000000000..fb67d686b23a7b --- /dev/null +++ b/homeassistant/components/conversation/http.py @@ -0,0 +1,325 @@ +"""HTTP endpoints for conversation integration.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from aiohttp import web +from hassil.recognize import ( + MISSING_ENTITY, + RecognizeResult, + UnmatchedRangeEntity, + UnmatchedTextEntity, +) +import voluptuous as vol + +from homeassistant.components import http, websocket_api +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import config_validation as cv, intent +from homeassistant.util import language as language_util + +from .agent_manager import agent_id_validator, async_converse, get_agent_manager +from .const import HOME_ASSISTANT_AGENT +from .default_agent import ( + METADATA_CUSTOM_FILE, + METADATA_CUSTOM_SENTENCE, + DefaultAgent, + SentenceTriggerResult, +) +from .models import ConversationInput + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the HTTP API for the conversation integration.""" + hass.http.register_view(ConversationProcessView()) + websocket_api.async_register_command(hass, websocket_process) + websocket_api.async_register_command(hass, websocket_prepare) + websocket_api.async_register_command(hass, websocket_list_agents) + websocket_api.async_register_command(hass, websocket_hass_agent_debug) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/process", + vol.Required("text"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("language"): str, + vol.Optional("agent_id"): agent_id_validator, + } +) +@websocket_api.async_response +async def websocket_process( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Process text.""" + result = await async_converse( + hass=hass, + text=msg["text"], + conversation_id=msg.get("conversation_id"), + context=connection.context(msg), + language=msg.get("language"), + agent_id=msg.get("agent_id"), + ) + connection.send_result(msg["id"], result.as_dict()) + + +@websocket_api.websocket_command( + { + "type": "conversation/prepare", + vol.Optional("language"): str, + vol.Optional("agent_id"): agent_id_validator, + } +) +@websocket_api.async_response +async def websocket_prepare( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Reload intents.""" + manager = get_agent_manager(hass) + agent = await manager.async_get_agent(msg.get("agent_id")) + await agent.async_prepare(msg.get("language")) + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/list", + vol.Optional("language"): str, + vol.Optional("country"): str, + } +) +@websocket_api.async_response +async def websocket_list_agents( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """List conversation agents and, optionally, if they support a given language.""" + manager = get_agent_manager(hass) + + country = msg.get("country") + language = msg.get("language") + agents = [] + + for agent_info in manager.async_get_agent_info(): + agent = await manager.async_get_agent(agent_info.id) + + supported_languages = agent.supported_languages + if language and supported_languages != MATCH_ALL: + supported_languages = language_util.matches( + language, supported_languages, country + ) + + agent_dict: dict[str, Any] = { + "id": agent_info.id, + "name": agent_info.name, + "supported_languages": supported_languages, + } + agents.append(agent_dict) + + connection.send_message(websocket_api.result_message(msg["id"], {"agents": agents})) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/homeassistant/debug", + vol.Required("sentences"): [str], + vol.Optional("language"): str, + vol.Optional("device_id"): vol.Any(str, None), + } +) +@websocket_api.async_response +async def websocket_hass_agent_debug( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Return intents that would be matched by the default agent for a list of sentences.""" + agent = await get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + assert isinstance(agent, DefaultAgent) + results = [ + await agent.async_recognize( + ConversationInput( + text=sentence, + context=connection.context(msg), + conversation_id=None, + device_id=msg.get("device_id"), + language=msg.get("language", hass.config.language), + ) + ) + for sentence in msg["sentences"] + ] + + # Return results for each sentence in the same order as the input. + result_dicts: list[dict[str, Any] | None] = [] + for result in results: + result_dict: dict[str, Any] | None = None + if isinstance(result, SentenceTriggerResult): + result_dict = { + # Matched a user-defined sentence trigger. + # We can't provide the response here without executing the + # trigger. + "match": True, + "source": "trigger", + "sentence_template": result.sentence_template or "", + } + elif isinstance(result, RecognizeResult): + successful_match = not result.unmatched_entities + result_dict = { + # Name of the matching intent (or the closest) + "intent": { + "name": result.intent.name, + }, + # Slot values that would be received by the intent + "slots": { # direct access to values + entity_key: entity.text or entity.value + for entity_key, entity in result.entities.items() + }, + # Extra slot details, such as the originally matched text + "details": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in result.entities.items() + }, + # Entities/areas/etc. that would be targeted + "targets": {}, + # True if match was successful + "match": successful_match, + # Text of the sentence template that matched (or was closest) + "sentence_template": "", + # When match is incomplete, this will contain the best slot guesses + "unmatched_slots": _get_unmatched_slots(result), + } + + if successful_match: + result_dict["targets"] = { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets(hass, result) + } + + if result.intent_sentence is not None: + result_dict["sentence_template"] = result.intent_sentence.text + + # Inspect metadata to determine if this matched a custom sentence + if result.intent_metadata and result.intent_metadata.get( + METADATA_CUSTOM_SENTENCE + ): + result_dict["source"] = "custom" + result_dict["file"] = result.intent_metadata.get(METADATA_CUSTOM_FILE) + else: + result_dict["source"] = "builtin" + + result_dicts.append(result_dict) + + connection.send_result(msg["id"], {"results": result_dicts}) + + +def _get_debug_targets( + hass: HomeAssistant, + result: RecognizeResult, +) -> Iterable[tuple[State, bool]]: + """Yield state/is_matched pairs for a hassil recognition.""" + entities = result.entities + + name: str | None = None + area_name: str | None = None + domains: set[str] | None = None + device_classes: set[str] | None = None + state_names: set[str] | None = None + + if "name" in entities: + name = str(entities["name"].value) + + if "area" in entities: + area_name = str(entities["area"].value) + + if "domain" in entities: + domains = set(cv.ensure_list(entities["domain"].value)) + + if "device_class" in entities: + device_classes = set(cv.ensure_list(entities["device_class"].value)) + + if "state" in entities: + # HassGetState only + state_names = set(cv.ensure_list(entities["state"].value)) + + if ( + (name is None) + and (area_name is None) + and (not domains) + and (not device_classes) + and (not state_names) + ): + # Avoid "matching" all entities when there is no filter + return + + states = intent.async_match_states( + hass, + name=name, + area_name=area_name, + domains=domains, + device_classes=device_classes, + ) + + for state in states: + # For queries, a target is "matched" based on its state + is_matched = (state_names is None) or (state.state in state_names) + yield state, is_matched + + +def _get_unmatched_slots( + result: RecognizeResult, +) -> dict[str, str | int]: + """Return a dict of unmatched text/range slot entities.""" + unmatched_slots: dict[str, str | int] = {} + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text == MISSING_ENTITY: + # Don't report since these are just missing context + # slots. + continue + + unmatched_slots[entity.name] = entity.text + elif isinstance(entity, UnmatchedRangeEntity): + unmatched_slots[entity.name] = entity.value + + return unmatched_slots + + +class ConversationProcessView(http.HomeAssistantView): + """View to process text.""" + + url = "/api/conversation/process" + name = "api:conversation:process" + + @RequestDataValidator( + vol.Schema( + { + vol.Required("text"): str, + vol.Optional("conversation_id"): str, + vol.Optional("language"): str, + vol.Optional("agent_id"): agent_id_validator, + } + ) + ) + async def post(self, request: web.Request, data: dict[str, str]) -> web.Response: + """Send a request for processing.""" + hass = request.app[http.KEY_HASS] + + result = await async_converse( + hass, + text=data["text"], + conversation_id=data.get("conversation_id"), + context=self.context(request), + language=data.get("language"), + agent_id=data.get("agent_id"), + ) + + return self.json(result.as_dict()) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7f3c4f5894ef66..7f463483bf934d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.27"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.29"] } diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/models.py similarity index 100% rename from homeassistant/components/conversation/agent.py rename to homeassistant/components/conversation/models.py diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 0fadc458352610..05fea054bca3ac 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -14,8 +14,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType -from . import HOME_ASSISTANT_AGENT, _get_agent_manager -from .const import DOMAIN +from .agent_manager import get_agent_manager +from .const import DOMAIN, HOME_ASSISTANT_AGENT from .default_agent import DefaultAgent @@ -111,7 +111,7 @@ async def call_action( # two trigger copies for who will provide a response. return None - default_agent = await _get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) + default_agent = await get_agent_manager(hass).async_get_agent(HOME_ASSISTANT_AGENT) assert isinstance(default_agent, DefaultAgent) return default_agent.register_trigger(sentences, call_action) diff --git a/homeassistant/components/deconz/hub/api.py b/homeassistant/components/deconz/hub/api.py index 71551ead6e1854..916c34672d80a8 100644 --- a/homeassistant/components/deconz/hub/api.py +++ b/homeassistant/components/deconz/hub/api.py @@ -26,7 +26,6 @@ async def get_deconz_api( try: async with asyncio.timeout(10): await api.refresh_state() - return api except errors.Unauthorized as err: LOGGER.warning("Invalid key for deCONZ at %s", config.host) @@ -35,3 +34,4 @@ async def get_deconz_api( except (TimeoutError, errors.RequestError, errors.ResponseError) as err: LOGGER.error("Error connecting to deCONZ gateway at %s", config.host) raise CannotConnect from err + return api diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 128822cf289245..41fa49f1a94f5b 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.3", "getmac==0.9.4"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index aaa6e1ee7dee24..c87e5e87779969 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.38.2"], + "requirements": ["async-upnp-client==0.38.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 17c0677e4d963c..d25459b95b7375 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.1.1"] + "requirements": ["aiodns==3.2.0"] } diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 48404e6dbeeac1..ce7b36f22800ff 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -139,10 +139,10 @@ def update_closest_store(self): """Update the shared closest store (if open).""" try: self.closest_store = self.address.closest_store() - return True except StoreException: self.closest_store = None return False + return True def get_menu(self): """Return the products from the closest stores menu.""" diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index ddc8ae0bdc5aa4..961a3287799b22 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -107,8 +107,6 @@ async def async_camera_image( response = await websession.get(self._url) self._last_image = await response.read() - self._last_update = now - return self._last_image except TimeoutError: _LOGGER.error("DoorBird %s: Camera image timed out", self.name) return self._last_image @@ -118,6 +116,9 @@ async def async_camera_image( ) return self._last_image + self._last_update = now + return self._last_image + async def async_added_to_hass(self) -> None: """Subscribe to events.""" await super().async_added_to_hass() diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py index 60e8351cc24e1e..e89fd4361a535d 100644 --- a/homeassistant/components/dovado/__init__.py +++ b/homeassistant/components/dovado/__init__.py @@ -74,10 +74,11 @@ def update(self): if not self.state: return False self.state.update(connected=self.state.get("modem status") == "CONNECTED") - _LOGGER.debug("Received: %s", self.state) - return True except OSError as error: _LOGGER.warning("Could not contact the router: %s", error) + return None + _LOGGER.debug("Received: %s", self.state) + return True @property def client(self): diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index debfc335496158..c9386999faeb28 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -67,21 +67,20 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: ebusdpy.init(server_address) - hass.data[DOMAIN] = EbusdData(server_address, circuit) - - sensor_config = { - CONF_MONITORED_CONDITIONS: monitored_conditions, - "client_name": name, - "sensor_types": SENSOR_TYPES[circuit], - } - load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config) - - hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write) - - _LOGGER.debug("Ebusd integration setup completed") - return True except (TimeoutError, OSError): return False + hass.data[DOMAIN] = EbusdData(server_address, circuit) + sensor_config = { + CONF_MONITORED_CONDITIONS: monitored_conditions, + "client_name": name, + "sensor_types": SENSOR_TYPES[circuit], + } + load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config) + + hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write) + + _LOGGER.debug("Ebusd integration setup completed") + return True class EbusdData: diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py index ae0b353a1dfe76..3b04325bd50d17 100644 --- a/homeassistant/components/ecoforest/coordinator.py +++ b/homeassistant/components/ecoforest/coordinator.py @@ -32,7 +32,8 @@ async def _async_update_data(self) -> Device: """Fetch all device and sensor data from api.""" try: data = await self.api.get() - _LOGGER.debug("Ecoforest data: %s", data) - return data except EcoforestError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + _LOGGER.debug("Ecoforest data: %s", data) + return data diff --git a/homeassistant/components/energenie_power_sockets/__init__.py b/homeassistant/components/energenie_power_sockets/__init__.py new file mode 100644 index 00000000000000..12ddb0d138902a --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/__init__.py @@ -0,0 +1,44 @@ +"""Energenie Power-Sockets (EGPS) integration.""" + +from pyegps import PowerStripUSB, get_device +from pyegps.exceptions import MissingLibrary, UsbError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_DEVICE_API_ID, DOMAIN + +PLATFORMS = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Energenie Power Sockets.""" + try: + powerstrip: PowerStripUSB | None = get_device(entry.data[CONF_DEVICE_API_ID]) + + except (MissingLibrary, UsbError) as ex: + raise ConfigEntryError("Can't access usb devices.") from ex + + if powerstrip is None: + raise ConfigEntryNotReady( + "Can't access Energenie Power Sockets, will retry later." + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = powerstrip + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + powerstrip = hass.data[DOMAIN].pop(entry.entry_id) + powerstrip.release() + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok diff --git a/homeassistant/components/energenie_power_sockets/config_flow.py b/homeassistant/components/energenie_power_sockets/config_flow.py new file mode 100644 index 00000000000000..ab39427f15a2d6 --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/config_flow.py @@ -0,0 +1,55 @@ +"""ConfigFlow for Energenie-Power-Sockets devices.""" + +from typing import Any + +from pyegps import get_device, search_for_devices +from pyegps.exceptions import MissingLibrary, UsbError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_DEVICE_API_ID, DOMAIN, LOGGER + + +class EGPSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for EGPS devices.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Initiate user flow.""" + + if user_input is not None: + dev_id = user_input[CONF_DEVICE_API_ID] + dev = await self.hass.async_add_executor_job(get_device, dev_id) + if dev is not None: + await self.async_set_unique_id(dev.device_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=dev_id, + data={CONF_DEVICE_API_ID: dev_id}, + ) + return self.async_abort(reason="device_not_found") + + currently_configured = self._async_current_ids(include_ignore=True) + try: + found_devices = await self.hass.async_add_executor_job(search_for_devices) + except (MissingLibrary, UsbError): + LOGGER.exception("Unable to access USB devices") + return self.async_abort(reason="usb_error") + + devices = [ + d + for d in found_devices + if d.get_device_type() == "PowerStrip" + and d.device_id not in currently_configured + ] + LOGGER.debug("Found %d devices", len(devices)) + if len(devices) > 0: + options = {d.device_id: f"{d.name} ({d.device_id})" for d in devices} + data_schema = {CONF_DEVICE_API_ID: vol.In(options)} + else: + return self.async_abort(reason="no_device") + + return self.async_show_form(step_id="user", data_schema=vol.Schema(data_schema)) diff --git a/homeassistant/components/energenie_power_sockets/const.py b/homeassistant/components/energenie_power_sockets/const.py new file mode 100644 index 00000000000000..a02373815c2d52 --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/const.py @@ -0,0 +1,8 @@ +"""Constants for Energenie Power Sockets.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_DEVICE_API_ID = "api-device-id" +DOMAIN = "energenie_power_sockets" diff --git a/homeassistant/components/energenie_power_sockets/manifest.json b/homeassistant/components/energenie_power_sockets/manifest.json new file mode 100644 index 00000000000000..8a55a539e7fe56 --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "energenie_power_sockets", + "name": "Energenie Power Sockets", + "codeowners": ["@gnumpi"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/energenie_power_sockets", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pyegps"], + "requirements": ["pyegps==0.2.5"] +} diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json new file mode 100644 index 00000000000000..e193b06b25fbe8 --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -0,0 +1,27 @@ +{ + "title": "Energenie Power Sockets Integration.", + "config": { + "step": { + "user": { + "title": "Searching for Energenie-Power-Sockets Devices.", + "description": "Choose a discovered device.", + "data": { + "device": "[%key:common::config_flow::data::device%]" + } + } + }, + "abort": { + "usb_error": "Couldn't access USB devices!", + "no_device": "Unable to discover any (new) supported device.", + "device_not_found": "No device was found for the given id.", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "switch": { + "socket": { + "name": "Socket {socket_id}" + } + } + } +} diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py new file mode 100644 index 00000000000000..1d5b9ed5197bd7 --- /dev/null +++ b/homeassistant/components/energenie_power_sockets/switch.py @@ -0,0 +1,77 @@ +"""Switch implementation for Energenie-Power-Sockets Platform.""" + +from typing import Any + +from pyegps import __version__ as PYEGPS_VERSION +from pyegps.exceptions import EgpsException +from pyegps.powerstrip import PowerStrip + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add EGPS sockets for passed config_entry in HA.""" + powerstrip: PowerStrip = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ( + EGPowerStripSocket(powerstrip, socket) + for socket in range(powerstrip.numberOfSockets) + ), + update_before_add=True, + ) + + +class EGPowerStripSocket(SwitchEntity): + """Represents a socket of an Energenie-Socket-Strip.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + _attr_has_entity_name = True + _attr_translation_key = "socket" + + def __init__(self, dev: PowerStrip, socket: int) -> None: + """Initiate a new socket.""" + self._dev = dev + self._socket = socket + self._attr_translation_placeholders = {"socket_id": str(socket)} + + self._attr_unique_id = f"{dev.device_id}_{socket}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, dev.device_id)}, + name=dev.name, + manufacturer=dev.manufacturer, + model=dev.name, + sw_version=PYEGPS_VERSION, + ) + + def turn_on(self, **kwargs: Any) -> None: + """Switch the socket on.""" + try: + self._dev.switch_on(self._socket) + except EgpsException as err: + raise HomeAssistantError(f"Couldn't access USB device: {err}") from err + + def turn_off(self, **kwargs: Any) -> None: + """Switch the socket off.""" + try: + self._dev.switch_off(self._socket) + except EgpsException as err: + raise HomeAssistantError(f"Couldn't access USB device: {err}") from err + + def update(self) -> None: + """Read the current state from the device.""" + try: + self._attr_is_on = self._dev.get_status(self._socket) + except EgpsException as err: + raise HomeAssistantError(f"Couldn't access USB device: {err}") from err diff --git a/homeassistant/components/enocean/dongle.py b/homeassistant/components/enocean/dongle.py index 6402b4c3a28c01..2d9a3f8787e3dd 100644 --- a/homeassistant/components/enocean/dongle.py +++ b/homeassistant/components/enocean/dongle.py @@ -82,7 +82,7 @@ def validate_path(path: str): # Creating the serial communicator will raise an exception # if it cannot connect SerialCommunicator(port=path) - return True except serial.SerialException as exception: _LOGGER.warning("Dongle path %s is invalid: %s", path, str(exception)) return False + return True diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index c8152d44726901..a508d5127d6d09 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -147,8 +147,6 @@ async def _async_update_data(self) -> dict[str, Any]: self._async_mark_setup_complete() # dump all received data in debug mode to assist troubleshooting envoy_data = await envoy.update() - _LOGGER.debug("Envoy data: %s", envoy_data) - return envoy_data.raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate @@ -157,5 +155,7 @@ async def _async_update_data(self) -> dict[str, Any]: raise ConfigEntryAuthFailed from err except EnvoyError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + _LOGGER.debug("Envoy data: %s", envoy_data) + return envoy_data.raw raise RuntimeError("Unreachable code in _async_update_data") # pragma: no cover diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index ef16a9df1b12cd..ed2fbcf1e8311c 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -43,7 +43,6 @@ def is_json(json_str: str) -> bool: """Validate if a String is a JSON value or not.""" try: json.loads(json_str) - return True except (ValueError, TypeError) as err: _LOGGER.error( "Failed to parse JSON '%s', error '%s'", @@ -51,6 +50,7 @@ def is_json(json_str: str) -> bool: err, ) return False + return True async def get_api(hass: HomeAssistant, host: str) -> Freepybox: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 8e773e74c757e8..b9d77220d7c302 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -789,24 +789,26 @@ async def _async_service_call( **kwargs, ) ) - return result except FritzSecurityError: _LOGGER.exception( "Authorization Error: Please check the provided credentials and" " verify that you can log into the web interface" ) + return {} except FRITZ_EXCEPTIONS: _LOGGER.exception( "Service/Action Error: cannot execute service %s with action %s", service_name, action_name, ) + return {} except FritzConnectionException: _LOGGER.exception( "Connection Error: Please check the device is properly configured" " for remote login" ) - return {} + return {} + return result async def async_get_upnp_configuration(self) -> dict[str, Any]: """Call X_AVM-DE_UPnP service.""" diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 377d46eceff95b..fe4cf82b29bc5a 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -82,13 +82,13 @@ def _try_connect(self) -> str: fritzbox.login() fritzbox.get_device_elements() fritzbox.logout() - return RESULT_SUCCESS except LoginError: return RESULT_INVALID_AUTH except HTTPError: return RESULT_NOT_SUPPORTED except OSError: return RESULT_NO_DEVICES_FOUND + return RESULT_SUCCESS async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index ac0d3ea3337ea6..019326d840c2c3 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -109,9 +109,6 @@ def _try_connect(self) -> ConnectResult: address=self._host, user=self._username, password=self._password ) info = fritz_connection.updatecheck - self._serial_number = info[FRITZ_ATTR_SERIAL_NUMBER] - - return ConnectResult.SUCCESS except RequestsConnectionError: return ConnectResult.NO_DEVIES_FOUND except FritzSecurityError: @@ -119,6 +116,9 @@ def _try_connect(self) -> ConnectResult: except FritzConnectionException: return ConnectResult.INVALID_AUTH + self._serial_number = info[FRITZ_ATTR_SERIAL_NUMBER] + return ConnectResult.SUCCESS + async def _get_name_of_phonebook(self, phonebook_id: int) -> str: """Return name of phonebook for given phonebook_id.""" phonebook_info = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9e86436bd68942..7864801a986365 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240328.0"] + "requirements": ["home-assistant-frontend==20240329.1"] } diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 9a1b281eec2949..4e5bdcc154309d 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for Glances integration.""" +from datetime import datetime, timedelta import logging from typing import Any @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_duration, utcnow from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -42,4 +44,16 @@ async def _async_update_data(self) -> dict[str, Any]: raise ConfigEntryAuthFailed from err except exceptions.GlancesApiError as err: raise UpdateFailed from err + # Update computed values + uptime: datetime | None = self.data["computed"]["uptime"] if self.data else None + up_duration: timedelta | None = None + if up_duration := parse_duration(data.get("uptime")): + # Update uptime if previous value is None or previous uptime is bigger than + # new uptime (i.e. server restarted) + if ( + self.data is None + or self.data["computed"]["uptime_duration"] > up_duration + ): + uptime = utcnow() - up_duration + data["computed"] = {"uptime_duration": up_duration, "uptime": uptime} return data or {} diff --git a/homeassistant/components/glances/icons.json b/homeassistant/components/glances/icons.json index 6a8c2fa728c243..06f8cd98a07c2e 100644 --- a/homeassistant/components/glances/icons.json +++ b/homeassistant/components/glances/icons.json @@ -45,6 +45,9 @@ }, "raid_used": { "default": "mdi:harddisk" + }, + "uptime": { + "default": "mdi:clock-time-eight-outline" } } } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 7db06a084962ca..5c22154aeef09b 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -3,14 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, - StateType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,7 +17,7 @@ UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -212,6 +210,12 @@ class GlancesSensorEntityDescription(SensorEntityDescription): translation_key="raid_used", state_class=SensorStateClass.MEASUREMENT, ), + ("computed", "uptime"): GlancesSensorEntityDescription( + key="uptime", + type="computed", + translation_key="uptime", + device_class=SensorDeviceClass.TIMESTAMP, + ), } @@ -276,6 +280,7 @@ def __init__( self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}-{sensor_label}-{description.key}" ) + self._update_native_value() @property def available(self) -> bool: @@ -289,13 +294,18 @@ def available(self) -> bool: ) return False - @property - def native_value(self) -> StateType: - """Return the state of the resources.""" - value = self.coordinator.data[self.entity_description.type] + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_native_value() + super()._handle_coordinator_update() - if isinstance(value.get(self._sensor_label), dict): - return cast( - StateType, value[self._sensor_label][self.entity_description.key] - ) - return cast(StateType, value[self.entity_description.key]) + def _update_native_value(self) -> None: + """Update sensor native value from coordinator data.""" + data = self.coordinator.data[self.entity_description.type] + if dict_val := data.get(self._sensor_label): + self._attr_native_value = dict_val.get(self.entity_description.key) + elif self.entity_description.key in data: + self._attr_native_value = data.get(self.entity_description.key) + else: + self._attr_native_value = None diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index b0b535ce8edf65..10a4cb7ed00b83 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -100,6 +100,9 @@ }, "raid_used": { "name": "{sensor_label} used" + }, + "uptime": { + "name": "Uptime" } } }, diff --git a/homeassistant/components/hassio/data.py b/homeassistant/components/hassio/data.py index eaa7c2431fe259..a00335d44a26e3 100644 --- a/homeassistant/components/hassio/data.py +++ b/homeassistant/components/hassio/data.py @@ -483,28 +483,28 @@ async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | No """Update single addon stats.""" try: stats = await self.hassio.get_addon_stats(slug) - return (slug, stats) except HassioAPIError as err: _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) - return (slug, None) + return (slug, None) + return (slug, stats) async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]: """Return the changelog for an add-on.""" try: changelog = await self.hassio.get_addon_changelog(slug) - return (slug, changelog) except HassioAPIError as err: _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err) - return (slug, None) + return (slug, None) + return (slug, changelog) async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: info = await self.hassio.get_addon_info(slug) - return (slug, info) except HassioAPIError as err: _LOGGER.warning("Could not fetch info for %s: %s", slug, err) - return (slug, None) + return (slug, None) + return (slug, info) @callback def async_enable_container_updates( diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index ffb67730fa5040..826c7a27b985d8 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -188,15 +188,13 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: async for data, _ in client.content.iter_chunks(): await response.write(data) - return response - except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) - - except TimeoutError: + raise HTTPBadGateway from err + except TimeoutError as err: _LOGGER.error("Client timeout error on API request %s", path) - - raise HTTPBadGateway + raise HTTPBadGateway from err + return response get = _handle post = _handle diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index ed7c768e161c26..1573ff3f23e801 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -478,7 +478,6 @@ async def get_sources(): if controller.is_signed_in: favorites = await controller.get_favorites() inputs = await controller.get_input_sources() - return favorites, inputs except HeosError as error: if retry_attempts < self.max_retry_attempts: retry_attempts += 1 @@ -488,7 +487,9 @@ async def get_sources(): await asyncio.sleep(self.retry_delay) else: _LOGGER.error("Unable to update sources: %s", error) - return + return None + else: + return favorites, inputs async def update_sources(event, data=None): if event in ( diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index ba672e381063db..dec15e25b0ba23 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -95,10 +95,10 @@ def _login(self): return False try: self._userid = res.cookies["userid"] - return True except KeyError: _LOGGER.error("Failed to log in to router") return False + return True def _update_info(self): """Get ARP from router.""" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index d47d9775ed2dd4..5f1a9428d86251 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -494,11 +494,12 @@ async def stop_stream(self, session_info: dict[str, Any]) -> None: _LOGGER.info("[%s] %s stream", session_id, shutdown_method) try: await getattr(stream, shutdown_method)() - return except Exception: # pylint: disable=broad-except _LOGGER.exception( "[%s] Failed to %s stream", session_id, shutdown_method ) + else: + return async def reconfigure_stream( self, session_info: dict[str, Any], stream_config: dict[str, Any] diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 031cdbbc9bd768..642669cfc8d762 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -572,11 +572,12 @@ def _async_find_next_available_port(start_port: int, exclude_ports: set) -> int: continue try: test_socket.bind(("", port)) - return port except OSError: if port == MAX_PORT: raise continue + else: + return port raise RuntimeError("unreachable") @@ -584,10 +585,9 @@ def pid_is_alive(pid: int) -> bool: """Check to see if a process is alive.""" try: os.kill(pid, 0) - return True except OSError: - pass - return False + return False + return True def accessory_friendly_name(hass_name: str, accessory: Accessory) -> str: diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 058b7ec6c00c03..7825999900e52f 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -39,9 +39,9 @@ async def async_setup(self) -> bool: self.auth = await self.get_auth( self.hass, self.config.get(HMIPC_HAPID), self.config.get(HMIPC_PIN) ) - return self.auth is not None except HmipcConnectionError: return False + return self.auth is not None async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" @@ -55,9 +55,9 @@ async def async_register(self): try: authtoken = await self.auth.requestAuthToken() await self.auth.confirmAuthToken(authtoken) - return authtoken except HmipConnectionError: return False + return authtoken async def get_auth(self, hass: HomeAssistant, hapid, pin): """Create a HomematicIP access point object.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index e75fef42ef3d20..cef5bc5030eea3 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -79,9 +79,9 @@ def format_last_reset_elapsed_seconds(value: str | None) -> datetime | None: try: last_reset = datetime.now() - timedelta(seconds=int(value)) last_reset.replace(microsecond=0) - return last_reset except ValueError: return None + return last_reset def signal_icon(limits: Sequence[int], value: StateType) -> str: diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index f7c90f3420b213..ecb71f9653ae2d 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -119,4 +119,5 @@ def __init__( async def async_press(self) -> None: """Handle the button press.""" - await self.entity_description.press_action(self._shade) + async with self.coordinator.radio_operation_lock: + await self.entity_description.press_action(self._shade) diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 1ea47ca9d1fa6d..f074b06b2bc570 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging @@ -25,6 +26,10 @@ def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None: """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades self.hub = hub + # The hub tends to crash if there are multiple radio operations at the same time + # but it seems to handle all other requests that do not use RF without issue + # so we have a lock to prevent multiple radio operations at the same time + self.radio_operation_lock = asyncio.Lock() super().__init__( hass, _LOGGER, diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 453d5c4e920577..57409f37ac9e84 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -67,7 +67,8 @@ async def _async_initial_refresh() -> None: for shade in pv_entry.shade_data.values(): _LOGGER.debug("Initial refresh of shade: %s", shade.name) - await shade.refresh(suppress_timeout=True) # default 15 second timeout + async with coordinator.radio_operation_lock: + await shade.refresh(suppress_timeout=True) # default 15 second timeout entities: list[ShadeEntity] = [] for shade in pv_entry.shade_data.values(): @@ -207,7 +208,8 @@ def _get_shade_move(self, target_hass_position: int) -> ShadePosition: async def _async_execute_move(self, move: ShadePosition) -> None: """Execute a move that can affect multiple positions.""" _LOGGER.debug("Move request %s: %s", self.name, move) - response = await self._shade.move(move) + async with self.coordinator.radio_operation_lock: + response = await self._shade.move(move) _LOGGER.debug("Move response %s: %s", self.name, response) # Process the response from the hub (including new positions) @@ -318,7 +320,10 @@ async def async_update(self) -> None: # error if are already have one in flight return # suppress timeouts caused by hub nightly reboot - await self._shade.refresh(suppress_timeout=True) # default 15 second timeout + async with self.coordinator.radio_operation_lock: + await self._shade.refresh( + suppress_timeout=True + ) # default 15 second timeout _LOGGER.debug("Process update %s: %s", self.name, self._shade.current_position) self._async_update_shade_data(self._shade.current_position) diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 66207f6da7c151..f1e9c491659830 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -114,5 +114,6 @@ async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) # force update data to ensure new info is in coordinator - await self._shade.refresh() + async with self.coordinator.radio_operation_lock: + await self._shade.refresh(suppress_timeout=True) self.async_write_ha_state() diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index bca87189e5682d..b24193ac4386c4 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -153,5 +153,6 @@ def _async_update_shade_from_group(self) -> None: async def async_update(self) -> None: """Refresh sensor entity.""" - await self.entity_description.update_fn(self._shade) + async with self.coordinator.radio_operation_lock: + await self.entity_description.update_fn(self._shade) self.async_write_ha_state() diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 65cc85bd09bf69..ec11ef92d08d21 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -14,11 +14,17 @@ } }, "sensor": { + "error": { + "default": "mdi:alert-circle-outline" + }, "number_of_charging_cycles": { "default": "mdi:battery-sync-outline" }, "number_of_collisions": { "default": "mdi:counter" + }, + "restricted_reason": { + "default": "mdi:tooltip-question" } } } diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index e054d02e3ba56c..10aec9b153653d 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -5,7 +5,7 @@ from datetime import datetime import logging -from aioautomower.model import MowerAttributes, MowerModes +from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons from homeassistant.components.sensor import ( SensorDeviceClass, @@ -26,6 +26,165 @@ _LOGGER = logging.getLogger(__name__) +ERROR_KEY_LIST = [ + "no_error", + "alarm_mower_in_motion", + "alarm_mower_lifted", + "alarm_mower_stopped", + "alarm_mower_switched_off", + "alarm_mower_tilted", + "alarm_outside_geofence", + "angular_sensor_problem", + "battery_problem", + "battery_problem", + "battery_restriction_due_to_ambient_temperature", + "can_error", + "charging_current_too_high", + "charging_station_blocked", + "charging_system_problem", + "charging_system_problem", + "collision_sensor_defect", + "collision_sensor_error", + "collision_sensor_problem_front", + "collision_sensor_problem_rear", + "com_board_not_available", + "communication_circuit_board_sw_must_be_updated", + "complex_working_area", + "connection_changed", + "connection_not_changed", + "connectivity_problem", + "connectivity_problem", + "connectivity_problem", + "connectivity_problem", + "connectivity_problem", + "connectivity_problem", + "connectivity_settings_restored", + "cutting_drive_motor_1_defect", + "cutting_drive_motor_2_defect", + "cutting_drive_motor_3_defect", + "cutting_height_blocked", + "cutting_height_problem", + "cutting_height_problem_curr", + "cutting_height_problem_dir", + "cutting_height_problem_drive", + "cutting_motor_problem", + "cutting_stopped_slope_too_steep", + "cutting_system_blocked", + "cutting_system_blocked", + "cutting_system_imbalance_warning", + "cutting_system_major_imbalance", + "destination_not_reachable", + "difficult_finding_home", + "docking_sensor_defect", + "electronic_problem", + "empty_battery", + "folding_cutting_deck_sensor_defect", + "folding_sensor_activated", + "geofence_problem", + "geofence_problem", + "gps_navigation_problem", + "guide_1_not_found", + "guide_2_not_found", + "guide_3_not_found", + "guide_calibration_accomplished", + "guide_calibration_failed", + "high_charging_power_loss", + "high_internal_power_loss", + "high_internal_temperature", + "internal_voltage_error", + "invalid_battery_combination_invalid_combination_of_different_battery_types", + "invalid_sub_device_combination", + "invalid_system_configuration", + "left_brush_motor_overloaded", + "lift_sensor_defect", + "lifted", + "limited_cutting_height_range", + "limited_cutting_height_range", + "loop_sensor_defect", + "loop_sensor_problem_front", + "loop_sensor_problem_left", + "loop_sensor_problem_rear", + "loop_sensor_problem_right", + "low_battery", + "memory_circuit_problem", + "mower_lifted", + "mower_tilted", + "no_accurate_position_from_satellites", + "no_confirmed_position", + "no_drive", + "no_loop_signal", + "no_power_in_charging_station", + "no_response_from_charger", + "outside_working_area", + "poor_signal_quality", + "reference_station_communication_problem", + "right_brush_motor_overloaded", + "safety_function_faulty", + "settings_restored", + "sim_card_locked", + "sim_card_locked", + "sim_card_locked", + "sim_card_locked", + "sim_card_not_found", + "sim_card_requires_pin", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", + "slope_too_steep", + "sms_could_not_be_sent", + "stop_button_problem", + "stuck_in_charging_station", + "switch_cord_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "temporary_battery_problem", + "tilt_sensor_problem", + "too_high_discharge_current", + "too_high_internal_current", + "trapped", + "ultrasonic_problem", + "ultrasonic_sensor_1_defect", + "ultrasonic_sensor_2_defect", + "ultrasonic_sensor_3_defect", + "ultrasonic_sensor_4_defect", + "unexpected_cutting_height_adj", + "unexpected_error", + "upside_down", + "weak_gps_signal", + "wheel_drive_problem_left", + "wheel_drive_problem_rear_left", + "wheel_drive_problem_rear_right", + "wheel_drive_problem_right", + "wheel_motor_blocked_left", + "wheel_motor_blocked_rear_left", + "wheel_motor_blocked_rear_right", + "wheel_motor_blocked_right", + "wheel_motor_overloaded_left", + "wheel_motor_overloaded_rear_left", + "wheel_motor_overloaded_rear_right", + "wheel_motor_overloaded_right", + "work_area_not_valid", + "wrong_loop_signal", + "wrong_pin_code", + "zone_generator_problem", +] + +RESTRICTED_REASONS: list = [ + RestrictedReasons.ALL_WORK_AREAS_COMPLETED.lower(), + RestrictedReasons.DAILY_LIMIT.lower(), + RestrictedReasons.EXTERNAL.lower(), + RestrictedReasons.FOTA.lower(), + RestrictedReasons.FROST.lower(), + RestrictedReasons.NONE.lower(), + RestrictedReasons.NOT_APPLICABLE.lower(), + RestrictedReasons.PARK_OVERRIDE.lower(), + RestrictedReasons.SENSOR.lower(), + RestrictedReasons.WEEK_SCHEDULE.lower(), +] + @dataclass(frozen=True, kw_only=True) class AutomowerSensorEntityDescription(SensorEntityDescription): @@ -141,6 +300,22 @@ class AutomowerSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: dt_util.as_local(data.planner.next_start_datetime), ), + AutomowerSensorEntityDescription( + key="error", + translation_key="error", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: ( + "no_error" if data.mower.error_key is None else data.mower.error_key + ), + options=ERROR_KEY_LIST, + ), + AutomowerSensorEntityDescription( + key="restricted_reason", + translation_key="restricted_reason", + device_class=SensorDeviceClass.ENUM, + options=RESTRICTED_REASONS, + value_fn=lambda data: data.planner.restricted_reason.lower(), + ), ) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 8032c67040443e..0a2d3685c6e09d 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -49,6 +49,134 @@ } }, "sensor": { + "error": { + "name": "Error", + "state": { + "alarm_mower_in_motion": "Alarm! Mower in motion", + "alarm_mower_lifted": "Alarm! Mower lifted", + "alarm_mower_stopped": "Alarm! Mower stopped", + "alarm_mower_switched_off": "Alarm! Mower switched off", + "alarm_mower_tilted": "Alarm! Mower tilted", + "alarm_outside_geofence": "Alarm! Outside geofence", + "angular_sensor_problem": "Angular sensor problem", + "battery_problem": "Battery problem", + "battery_restriction_due_to_ambient_temperature": "Battery restriction due to ambient temperature", + "can_error": "CAN error", + "charging_current_too_high": "Charging current too high", + "charging_station_blocked": "Charging station blocked", + "charging_system_problem": "Charging system problem", + "collision_sensor_defect": "Collision sensor defect", + "collision_sensor_error": "Collision sensor error", + "collision_sensor_problem_front": "Front collision sensor problem", + "collision_sensor_problem_rear": "Rear collision sensor problem", + "com_board_not_available": "Com board not available", + "communication_circuit_board_sw_must_be_updated": "Communication circuit board software must be updated", + "complex_working_area": "Complex working area", + "connection_changed": "Connection changed", + "connection_not_changed": "Connection NOT changed", + "connectivity_problem": "Connectivity problem", + "connectivity_settings_restored": "Connectivity settings restored", + "cutting_drive_motor_1_defect": "Cutting drive motor 1 defect", + "cutting_drive_motor_2_defect": "Cutting drive motor 2 defect", + "cutting_drive_motor_3_defect": "Cutting drive motor 3 defect", + "cutting_height_blocked": "Cutting height blocked", + "cutting_height_problem": "Cutting height problem", + "cutting_height_problem_curr": "Cutting height problem, curr", + "cutting_height_problem_dir": "Cutting height problem, dir", + "cutting_height_problem_drive": "Cutting height problem, drive", + "cutting_motor_problem": "Cutting motor problem", + "cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep", + "cutting_system_blocked": "Cutting system blocked", + "cutting_system_imbalance_warning": "Cutting system imbalance", + "cutting_system_major_imbalance": "Cutting system major imbalance", + "destination_not_reachable": "Destination not reachable", + "difficult_finding_home": "Difficult finding home", + "docking_sensor_defect": "Docking sensor defect", + "electronic_problem": "Electronic problem", + "empty_battery": "Empty battery", + "folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect", + "folding_sensor_activated": "Folding sensor activated", + "geofence_problem": "Geofence problem", + "gps_navigation_problem": "GPS navigation problem", + "guide_1_not_found": "Guide 1 not found", + "guide_2_not_found": "Guide 2 not found", + "guide_3_not_found": "Guide 3 not found", + "guide_calibration_accomplished": "Guide calibration accomplished", + "guide_calibration_failed": "Guide calibration failed", + "high_charging_power_loss": "High charging power loss", + "high_internal_power_loss": "High internal power loss", + "high_internal_temperature": "High internal temperature", + "internal_voltage_error": "Internal voltage error", + "invalid_battery_combination_invalid_combination_of_different_battery_types": "Invalid battery combination - Invalid combination of different battery types.", + "invalid_sub_device_combination": "Invalid sub-device combination", + "invalid_system_configuration": "Invalid system configuration", + "left_brush_motor_overloaded": "Left brush motor overloaded", + "lift_sensor_defect": "Lift Sensor defect", + "lifted": "Lifted", + "limited_cutting_height_range": "Limited cutting height range", + "loop_sensor_defect": "Loop sensor defect", + "loop_sensor_problem_front": "Front loop sensor problem", + "loop_sensor_problem_left": "Left loop sensor problem", + "loop_sensor_problem_rear": "Rear loop sensor problem", + "loop_sensor_problem_right": "Right loop sensor problem", + "low_battery": "Low battery", + "memory_circuit_problem": "Memory circuit problem", + "mower_lifted": "Mower lifted", + "mower_tilted": "Mower tilted", + "no_accurate_position_from_satellites": "No accurate position from satellites", + "no_confirmed_position": "No confirmed position", + "no_drive": "No drive", + "no_error": "No error", + "no_loop_signal": "No loop signal", + "no_power_in_charging_station": "No power in charging station", + "no_response_from_charger": "No response from charger", + "outside_working_area": "Outside working area", + "poor_signal_quality": "Poor signal quality", + "reference_station_communication_problem": "Reference station communication problem", + "right_brush_motor_overloaded": "Right brush motor overloaded", + "safety_function_faulty": "Safety function faulty", + "settings_restored": "Settings restored", + "sim_card_locked": "SIM card locked", + "sim_card_not_found": "SIM card not found", + "sim_card_requires_pin": "SIM card requires PIN", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern": "Slipped - Mower has Slipped. Situation not solved with moving pattern", + "slope_too_steep": "Slope too steep", + "sms_could_not_be_sent": "SMS could not be sent", + "stop_button_problem": "STOP button problem", + "stuck_in_charging_station": "Stuck in charging station", + "switch_cord_problem": "Switch cord problem", + "temporary_battery_problem": "Temporary battery problem", + "tilt_sensor_problem": "Tilt sensor problem", + "too_high_discharge_current": "Discharge current too high", + "too_high_internal_current": "Internal current too high", + "trapped": "Trapped", + "ultrasonic_problem": "Ultrasonic problem", + "ultrasonic_sensor_1_defect": "Ultrasonic Sensor 1 defect", + "ultrasonic_sensor_2_defect": "Ultrasonic Sensor 2 defect", + "ultrasonic_sensor_3_defect": "Ultrasonic Sensor 3 defect", + "ultrasonic_sensor_4_defect": "Ultrasonic Sensor 4 defect", + "unexpected_cutting_height_adj": "Unexpected cutting height adjustment", + "unexpected_error": "Unexpected error", + "upside_down": "Upside down", + "weak_gps_signal": "Weak GPS signal", + "wheel_drive_problem_left": "Left wheel drive problem", + "wheel_drive_problem_rear_left": "Rear left wheel drive problem", + "wheel_drive_problem_rear_right": "Rear right wheel drive problem", + "wheel_drive_problem_right": "Right wheel drive problem", + "wheel_motor_blocked_left": "Left wheel motor blocked", + "wheel_motor_blocked_rear_left": "Rear left wheel motor blocked", + "wheel_motor_blocked_rear_right": "Rear right wheel motor blocked", + "wheel_motor_blocked_right": "Right wheel motor blocked", + "wheel_motor_overloaded_left": "Left wheel motor overloaded", + "wheel_motor_overloaded_rear_left": "Rear left wheel motor overloaded", + "wheel_motor_overloaded_rear_right": "Rear right wheel motor overloaded", + "wheel_motor_overloaded_right": "Right wheel motor overloaded", + "work_area_not_valid": "Work area not valid", + "wrong_loop_signal": "Wrong loop signal", + "wrong_pin_code": "Wrong PIN code", + "zone_generator_problem": "Zone generator problem" + } + }, "number_of_charging_cycles": { "name": "Number of charging cycles" }, @@ -58,6 +186,21 @@ "cutting_blade_usage_time": { "name": "Cutting blade usage time" }, + "restricted_reason": { + "name": "Restricted reason", + "state": { + "none": "No restrictions", + "week_schedule": "Week schedule", + "park_override": "Park override", + "sensor": "Weather timer", + "daily_limit": "Daily limit", + "fota": "Firmware Over-the-Air update running", + "frost": "Frost", + "all_work_areas_completed": "All work areas completed", + "external": "External", + "not_applicable": "Not applicable" + } + }, "total_charging_time": { "name": "Total charging time" }, diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 47767a004cb4d3..56dc7cc2cfacb5 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -199,7 +199,6 @@ async def _fetch_url(self, url: str) -> httpx.Response | None: url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True ) response.raise_for_status() - return response except httpx.TimeoutException: _LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url) return None @@ -211,6 +210,7 @@ async def _fetch_url(self, url: str) -> httpx.Response | None: err, ) return None + return response async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 78b52e06db37cb..7f857ff857f9d1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -264,6 +264,7 @@ async def _async_process_event(self, last_message_uid: str) -> None: "sender": message.sender, "subject": message.subject, "headers": message.headers, + "uid": last_message_uid, } if self.custom_event_template is not None: try: @@ -397,8 +398,6 @@ async def _async_update_data(self) -> int | None: """Update the number of unread emails.""" try: messages = await self._async_fetch_number_of_messages() - self.auth_errors = 0 - return messages except ( AioImapException, UpdateFailed, @@ -425,6 +424,9 @@ async def _async_update_data(self) -> int | None: self.async_set_update_error(ex) raise ConfigEntryAuthFailed from ex + self.auth_errors = 0 + return messages + class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 7eac51c600ee96..44aa1e18646fb1 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -72,12 +72,13 @@ async def _async_connect(**kwargs): """Connect to the Insteon modem.""" try: await async_connect(**kwargs) - _LOGGER.info("Connected to Insteon modem") - return True except ConnectionError: _LOGGER.error("Could not connect to Insteon modem") return False + _LOGGER.info("Connected to Insteon modem") + return True + def _remove_override(address, options): """Remove a device override from config.""" diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 62a0dbdec78add..ef587e405e671d 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -145,9 +145,9 @@ def validate_states(self, left: State, right: State) -> bool: def _is_numeric_state(state: State) -> bool: try: float(state.state) - return True except (ValueError, TypeError): return False + return True _NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = { diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index d7d33dabd44ffb..04ecd633d7036d 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -79,12 +79,14 @@ def get_ip_mode(host): """Get the 'mode' used to retrieve the MAC address.""" try: - if ipaddress.ip_address(host).version == 6: - return "ip6" - return "ip" + ip_address = ipaddress.ip_address(host) except ValueError: return "hostname" + if ip_address.version == 6: + return "ip6" + return "ip" + async def async_setup_platform( hass: HomeAssistant, diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 9fe87a2c3f654d..4e56314a6770c2 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -37,17 +37,17 @@ def dpt_value_validator(value: Any) -> str | int: def ga_validator(value: Any) -> str | int: """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" - if isinstance(value, (str, int)): - try: - parse_device_group_address(value) - return value - except CouldNotParseAddress as exc: - raise vol.Invalid( - f"'{value}' is not a valid KNX group address: {exc.message}" - ) from exc - raise vol.Invalid( - f"'{value}' is not a valid KNX group address: Invalid type '{type(value).__name__}'" - ) + if not isinstance(value, (str, int)): + raise vol.Invalid( + f"'{value}' is not a valid KNX group address: Invalid type '{type(value).__name__}'" + ) + try: + parse_device_group_address(value) + except CouldNotParseAddress as exc: + raise vol.Invalid( + f"'{value}' is not a valid KNX group address: {exc.message}" + ) from exc + return value ga_list_validator = vol.All( diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py index 4af004bddf5b48..2e31bcf9906fb0 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/hub.py @@ -55,7 +55,6 @@ async def login( load_robots=load_robots, subscribe_for_updates=subscribe_for_updates, ) - return except LitterRobotLoginException as ex: raise ConfigEntryAuthFailed("Invalid credentials") from ex except LitterRobotException as ex: diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index e2c85c1400b565..84ef3a2b7db177 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -77,7 +77,6 @@ async def async_update_data(force_refresh_token: bool = False) -> Lyric: try: async with asyncio.timeout(60): await lyric.get_locations() - return lyric except LyricAuthenticationException as exception: # Attempt to refresh the token before failing. # Honeywell appear to have issues keeping tokens saved. @@ -87,6 +86,7 @@ async def async_update_data(force_refresh_token: bool = False) -> Lyric: raise ConfigEntryAuthFailed from exception except (LyricException, ClientResponseError) as exception: raise UpdateFailed(exception) from exception + return lyric coordinator = DataUpdateCoordinator[Lyric]( hass, diff --git a/homeassistant/components/meteoclimatic/__init__.py b/homeassistant/components/meteoclimatic/__init__.py index 1e72925821812a..2c371ebdcfdf21 100644 --- a/homeassistant/components/meteoclimatic/__init__.py +++ b/homeassistant/components/meteoclimatic/__init__.py @@ -25,9 +25,9 @@ async def async_update_data(): data = await hass.async_add_executor_job( meteoclimatic_client.weather_at_station, station_code ) - return data.__dict__ except MeteoclimaticError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err + return data.__dict__ coordinator = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 6b94a62168302d..2830372f882a04 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -325,8 +325,6 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: entry[CONF_PASSWORD], **kwargs, ) - _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) - return api except ( librouteros.exceptions.LibRouterosError, OSError, @@ -336,3 +334,6 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: if "invalid user name or password" in str(api_error): raise LoginError from api_error raise CannotConnect from api_error + + _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) + return api diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index b932a33d0fa620..8e9fb5442ea9d8 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -71,9 +71,9 @@ def is_socket_address(value: str) -> str: """Validate that value is a valid address.""" try: socket.getaddrinfo(value, None) - return value except OSError as err: raise vol.Invalid("Device is not a valid domain name or ip address") from err + return value async def try_connect( diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py index abf280bece9e9a..15377bce56b2c7 100644 --- a/homeassistant/components/nextbus/coordinator.py +++ b/homeassistant/components/nextbus/coordinator.py @@ -72,8 +72,8 @@ def _update_data() -> dict: # Casting here because we expect dict and not a str due to the input format selected being JSON data = cast(dict[str, Any], data) self._calc_predictions(data) - return data except (NextBusHTTPError, NextBusFormatError) as ex: raise UpdateFailed("Failed updating nextbus data", ex) from ex + return data return await self.hass.async_add_executor_job(_update_data) diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 9eaca66119fd31..2cbec236261dbb 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -418,13 +418,13 @@ async def async_get_battery( server_info = await self.hass.async_add_executor_job( self.leaf.get_latest_battery_status ) - return server_info except CarwingsError: _LOGGER.error("An error occurred getting battery status") return None except (KeyError, TypeError): _LOGGER.error("An error occurred parsing response from server") return None + return server_info async def async_get_climate( self, diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 91920b4c32dbeb..344767c8cd10d4 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -109,15 +109,15 @@ def update(self) -> None: LOGGER.info("Connection restored") self._attr_available = True - return - except RequestException as exc: if self.requester.available: LOGGER.warning("Connection failed, Obihai offline? %s", exc) + self._attr_native_value = None + self._attr_available = False + self.requester.available = False except IndexError as exc: if self.requester.available: LOGGER.warning("Connection failed, bad response: %s", exc) - - self._attr_native_value = None - self._attr_available = False - self.requester.available = False + self._attr_native_value = None + self._attr_available = False + self.requester.available = False diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 71acf62f97d37e..b427cbda2f82b8 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -218,12 +218,13 @@ async def async_manually_set_date_and_time(self) -> None: try: await device_mgmt.SetSystemDateAndTime(dt_param) LOGGER.debug("%s: SetSystemDateAndTime: success", self.name) - return # Some cameras don't support setting the timezone and will throw an IndexError # if we try to set it. If we get an error, try again without the timezone. except (IndexError, Fault): if idx == timezone_max_idx: raise + else: + return async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 690a3739b4fd47..29da0fee35f375 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -221,9 +221,9 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: None, payload.Data.SimpleItem[0].Value == "true", ) - return evt except (AttributeError, KeyError): return None + return evt @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") diff --git a/homeassistant/components/open_meteo/__init__.py b/homeassistant/components/open_meteo/__init__.py index ac09b0f61a2b54..e3bf763f4291a7 100644 --- a/homeassistant/components/open_meteo/__init__.py +++ b/homeassistant/components/open_meteo/__init__.py @@ -5,6 +5,7 @@ from open_meteo import ( DailyParameters, Forecast, + HourlyParameters, OpenMeteo, OpenMeteoError, PrecipitationUnit, @@ -45,6 +46,11 @@ async def async_update_forecast() -> Forecast: DailyParameters.WIND_DIRECTION_10M_DOMINANT, DailyParameters.WIND_SPEED_10M_MAX, ], + hourly=[ + HourlyParameters.PRECIPITATION, + HourlyParameters.TEMPERATURE_2M, + HourlyParameters.WEATHER_CODE, + ], precipitation_unit=PrecipitationUnit.MILLIMETERS, temperature_unit=TemperatureUnit.CELSIUS, timezone="UTC", diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 8ee3edd5183284..a2be81f0928882 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -5,6 +5,12 @@ from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_WIND_BEARING, Forecast, SingleCoordinatorWeatherEntity, WeatherEntityFeature, @@ -15,6 +21,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP @@ -39,7 +46,9 @@ class OpenMeteoWeatherEntity( _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -95,31 +104,77 @@ def _async_forecast_daily(self) -> list[Forecast] | None: return None forecasts: list[Forecast] = [] + daily = self.coordinator.data.daily - for index, time in enumerate(self.coordinator.data.daily.time): + for index, date in enumerate(self.coordinator.data.daily.time): forecast = Forecast( - datetime=time.isoformat(), + datetime=date.isoformat(), ) if daily.weathercode is not None: - forecast["condition"] = WMO_TO_HA_CONDITION_MAP.get( + forecast[ATTR_FORECAST_CONDITION] = WMO_TO_HA_CONDITION_MAP.get( daily.weathercode[index] ) if daily.precipitation_sum is not None: - forecast["native_precipitation"] = daily.precipitation_sum[index] + forecast[ATTR_FORECAST_NATIVE_PRECIPITATION] = daily.precipitation_sum[ + index + ] if daily.temperature_2m_max is not None: - forecast["native_temperature"] = daily.temperature_2m_max[index] + forecast[ATTR_FORECAST_NATIVE_TEMP] = daily.temperature_2m_max[index] if daily.temperature_2m_min is not None: - forecast["native_templow"] = daily.temperature_2m_min[index] + forecast[ATTR_FORECAST_NATIVE_TEMP_LOW] = daily.temperature_2m_min[ + index + ] if daily.wind_direction_10m_dominant is not None: - forecast["wind_bearing"] = daily.wind_direction_10m_dominant[index] + forecast[ATTR_FORECAST_WIND_BEARING] = ( + daily.wind_direction_10m_dominant[index] + ) if daily.wind_speed_10m_max is not None: - forecast["native_wind_speed"] = daily.wind_speed_10m_max[index] + forecast[ATTR_FORECAST_NATIVE_WIND_SPEED] = daily.wind_speed_10m_max[ + index + ] + + forecasts.append(forecast) + + return forecasts + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + if self.coordinator.data.hourly is None: + return None + + forecasts: list[Forecast] = [] + + # Can have data in the past: https://github.com/open-meteo/open-meteo/issues/699 + today = dt_util.utcnow() + + hourly = self.coordinator.data.hourly + for index, datetime in enumerate(self.coordinator.data.hourly.time): + if dt_util.as_utc(datetime) < today: + continue + + forecast = Forecast( + datetime=datetime.isoformat(), + ) + + if hourly.weather_code is not None: + forecast[ATTR_FORECAST_CONDITION] = WMO_TO_HA_CONDITION_MAP.get( + hourly.weather_code[index] + ) + + if hourly.precipitation is not None: + forecast[ATTR_FORECAST_NATIVE_PRECIPITATION] = hourly.precipitation[ + index + ] + + if hourly.temperature_2m is not None: + forecast[ATTR_FORECAST_NATIVE_TEMP] = hourly.temperature_2m[index] forecasts.append(forecast) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 863b6050616b1f..3cfd1ad30a0788 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -100,19 +100,17 @@ async def async_step_init( if user_input[CONF_CONTRIBUTING_USER] and not authentication: errors["base"] = "no_authentication" if authentication and not errors: - async with OpenSky( - session=async_get_clientsession(self.hass) - ) as opensky: - try: - await opensky.authenticate( - BasicAuth( - login=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - ), - contributing_user=user_input[CONF_CONTRIBUTING_USER], - ) - except OpenSkyUnauthenticatedError: - errors["base"] = "invalid_auth" + opensky = OpenSky(session=async_get_clientsession(self.hass)) + try: + await opensky.authenticate( + BasicAuth( + login=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ), + contributing_user=user_input[CONF_CONTRIBUTING_USER], + ) + except OpenSkyUnauthenticatedError: + errors["base"] = "invalid_auth" if not errors: return self.async_create_entry( title=self.options.get(CONF_NAME, "OpenSky"), diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 871a70b3e0a936..c37afc9cb0c132 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -105,6 +105,22 @@ class OverkizBinarySensorDescription(BinarySensorEntityDescription): ) == 1, ), + OverkizBinarySensorDescription( + key=OverkizState.CORE_HEATING_STATUS, + name="Heating status", + device_class=BinarySensorDeviceClass.HEAT, + value_fn=lambda state: state == OverkizCommandParam.ON, + ), + OverkizBinarySensorDescription( + key=OverkizState.MODBUSLINK_DHW_ABSENCE_MODE, + name="Absence mode", + value_fn=lambda state: state == OverkizCommandParam.ON, + ), + OverkizBinarySensorDescription( + key=OverkizState.MODBUSLINK_DHW_BOOST_MODE, + name="Boost mode", + value_fn=lambda state: state == OverkizCommandParam.ON, + ), ] SUPPORTED_STATES = { diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index e23403c216296e..b569d05d2d7fe5 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData @@ -27,15 +28,16 @@ async def async_setup_entry( """Set up the Overkiz climate from a config entry.""" data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + # Match devices based on the widget. + entities_based_on_widget: list[Entity] = [ WIDGET_TO_CLIMATE_ENTITY[device.widget](device.device_url, data.coordinator) for device in data.platforms[Platform.CLIMATE] if device.widget in WIDGET_TO_CLIMATE_ENTITY - ) + ] - # Match devices based on the widget and controllableName - # This is for example used for Atlantic APC, where devices with different functionality share the same uiClass and widget. - async_add_entities( + # Match devices based on the widget and controllableName. + # ie Atlantic APC + entities_based_on_widget_and_controllable: list[Entity] = [ WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ cast(Controllable, device.controllable_name) ](device.device_url, data.coordinator) @@ -43,14 +45,21 @@ async def async_setup_entry( if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY and device.controllable_name in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] - ) + ] - # Hitachi Air To Air Heat Pumps - async_add_entities( + # Match devices based on the widget and protocol. + # #ie Hitachi Air To Air Heat Pumps + entities_based_on_widget_and_protocol: list[Entity] = [ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( device.device_url, data.coordinator ) for device in data.platforms[Platform.CLIMATE] if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget] + ] + + async_add_entities( + entities_based_on_widget + + entities_based_on_widget_and_controllable + + entities_based_on_widget_and_protocol ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index bf6aa43644edb7..3da2ccc922b852 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -159,7 +159,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self.async_set_heating_mode(PRESET_MODES_TO_OVERKIZ[preset_mode]) @property - def preset_mode(self) -> str: + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" heating_mode = cast( str, self.executor.select_state(OverkizState.IO_PASS_APC_HEATING_MODE) @@ -179,7 +179,7 @@ def preset_mode(self) -> str: return OVERKIZ_TO_PRESET_MODES[heating_mode] @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return hvac target temperature.""" current_heating_profile = self.current_heating_profile if current_heating_profile in OVERKIZ_TEMPERATURE_STATE_BY_PROFILE: diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py index 261acc2838cc10..f18edd0cfe6be7 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py @@ -3,16 +3,24 @@ from __future__ import annotations from asyncio import sleep +from functools import cached_property from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState -from homeassistant.components.climate import PRESET_NONE, HVACMode -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES from ..coordinator import OverkizDataUpdateCoordinator +from ..executor import OverkizExecutor from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone -from .atlantic_pass_apc_zone_control import OVERKIZ_TO_HVAC_MODE PRESET_SCHEDULE = "schedule" PRESET_MANUAL = "manual" @@ -24,32 +32,127 @@ PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()} -TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 20 +# Maps the HVAC current ZoneControl system operating mode. +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.COOLING: HVACAction.COOLING, + OverkizCommandParam.DRYING: HVACAction.DRYING, + OverkizCommandParam.HEATING: HVACAction.HEATING, + # There is no known way to differentiate OFF from Idle. + OverkizCommandParam.STOP: HVACAction.OFF, +} + +HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE: dict[HVACAction, OverkizState] = { + HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_PROFILE, + HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_PROFILE, +} + +HVAC_ACTION_TO_OVERKIZ_MODE_STATE: dict[HVACAction, OverkizState] = { + HVACAction.COOLING: OverkizState.IO_PASS_APC_COOLING_MODE, + HVACAction.HEATING: OverkizState.IO_PASS_APC_HEATING_MODE, +} + +TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1 + +SUPPORTED_FEATURES: ClimateEntityFeature = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) + +OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE: dict[ + OverkizCommandParam, tuple[HVACMode, ClimateEntityFeature] +] = { + OverkizCommandParam.COOLING: ( + HVACMode.COOL, + SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE, + ), + OverkizCommandParam.HEATING: ( + HVACMode.HEAT, + SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE, + ), + OverkizCommandParam.HEATING_AND_COOLING: ( + HVACMode.HEAT_COOL, + SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ), +} -# Those device depends on a main probe that choose the operating mode (heating, cooling, ...) +# Those device depends on a main probe that choose the operating mode (heating, cooling, ...). class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): """Representation of Atlantic Pass APC Heating And Cooling Zone Control.""" + _attr_target_temperature_step = PRECISION_HALVES + def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator ) -> None: """Init method.""" super().__init__(device_url, coordinator) - # There is less supported functions, because they depend on the ZoneControl. - if not self.is_using_derogated_temperature_fallback: - # Modes are not configurable, they will follow current HVAC Mode of Zone Control. - self._attr_hvac_modes = [] + # When using derogated temperature, we fallback to legacy behavior. + if self.is_using_derogated_temperature_fallback: + return + + self._attr_hvac_modes = [] + self._attr_supported_features = ClimateEntityFeature(0) - # Those are available and tested presets on Shogun. - self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + # Modes depends on device capabilities. + if (thermal_configuration := self.thermal_configuration) is not None: + ( + device_hvac_mode, + climate_entity_feature, + ) = thermal_configuration + self._attr_hvac_modes = [device_hvac_mode, HVACMode.OFF] + self._attr_supported_features = climate_entity_feature + + # Those are available and tested presets on Shogun. + self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] # Those APC Heating and Cooling probes depends on the zone control device (main probe). # Only the base device (#1) can be used to get/set some states. # Like to retrieve and set the current operating mode (heating, cooling, drying, off). - self.zone_control_device = self.executor.linked_device( - TEMPERATURE_ZONECONTROL_DEVICE_INDEX + + self.zone_control_executor: OverkizExecutor | None = None + + if ( + zone_control_device := self.executor.linked_device( + TEMPERATURE_ZONECONTROL_DEVICE_INDEX + ) + ) is not None: + self.zone_control_executor = OverkizExecutor( + zone_control_device.device_url, + coordinator, + ) + + @cached_property + def thermal_configuration(self) -> tuple[HVACMode, ClimateEntityFeature] | None: + """Retrieve thermal configuration for this devices.""" + + if ( + ( + state_thermal_configuration := cast( + OverkizCommandParam | None, + self.executor.select_state(OverkizState.CORE_THERMAL_CONFIGURATION), + ) + ) + is not None + and state_thermal_configuration + in OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE + ): + return OVERKIZ_THERMAL_CONFIGURATION_TO_HVAC_MODE[ + state_thermal_configuration + ] + + return None + + @cached_property + def device_hvac_mode(self) -> HVACMode | None: + """ZoneControlZone device has a single possible mode.""" + + return ( + None + if self.thermal_configuration is None + else self.thermal_configuration[0] ) @property @@ -61,21 +164,37 @@ def is_using_derogated_temperature_fallback(self) -> bool: ) @property - def zone_control_hvac_mode(self) -> HVACMode: + def zone_control_hvac_action(self) -> HVACAction: """Return hvac operation ie. heat, cool, dry, off mode.""" - if ( - self.zone_control_device is not None - and ( - state := self.zone_control_device.states[ + if self.zone_control_executor is not None and ( + ( + state := self.zone_control_executor.select_state( OverkizState.IO_PASS_APC_OPERATING_MODE - ] + ) ) is not None - and (value := state.value_as_str) is not None ): - return OVERKIZ_TO_HVAC_MODE[value] - return HVACMode.OFF + return OVERKIZ_TO_HVAC_ACTION[cast(str, state)] + + return HVACAction.OFF + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation.""" + + # When ZoneControl action is heating/cooling but Zone is stopped, means the zone is idle. + if ( + hvac_action := self.zone_control_hvac_action + ) in HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE and cast( + str, + self.executor.select_state( + HVAC_ACTION_TO_OVERKIZ_PROFILE_STATE[hvac_action] + ), + ) == OverkizCommandParam.STOP: + return HVACAction.IDLE + + return hvac_action @property def hvac_mode(self) -> HVACMode: @@ -84,30 +203,32 @@ def hvac_mode(self) -> HVACMode: if self.is_using_derogated_temperature_fallback: return super().hvac_mode - zone_control_hvac_mode = self.zone_control_hvac_mode + if (device_hvac_mode := self.device_hvac_mode) is None: + return HVACMode.OFF - # Should be same, because either thermostat or this integration change both. - on_off_state = cast( + cooling_is_off = cast( str, - self.executor.select_state( - OverkizState.CORE_COOLING_ON_OFF - if zone_control_hvac_mode == HVACMode.COOL - else OverkizState.CORE_HEATING_ON_OFF - ), - ) + self.executor.select_state(OverkizState.CORE_COOLING_ON_OFF), + ) in (OverkizCommandParam.OFF, None) - # Device is Stopped, it means the air flux is flowing but its venting door is closed. - if on_off_state == OverkizCommandParam.OFF: - hvac_mode = HVACMode.OFF - else: - hvac_mode = zone_control_hvac_mode + heating_is_off = cast( + str, + self.executor.select_state(OverkizState.CORE_HEATING_ON_OFF), + ) in (OverkizCommandParam.OFF, None) - # It helps keep it consistent with the Zone Control, within the interface. - if self._attr_hvac_modes != [zone_control_hvac_mode, HVACMode.OFF]: - self._attr_hvac_modes = [zone_control_hvac_mode, HVACMode.OFF] - self.async_write_ha_state() + # Device is Stopped, it means the air flux is flowing but its venting door is closed. + if ( + (device_hvac_mode == HVACMode.COOL and cooling_is_off) + or (device_hvac_mode == HVACMode.HEAT and heating_is_off) + or ( + device_hvac_mode == HVACMode.HEAT_COOL + and cooling_is_off + and heating_is_off + ) + ): + return HVACMode.OFF - return hvac_mode + return device_hvac_mode async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -118,46 +239,49 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # They are mainly managed by the Zone Control device # However, it make sense to map the OFF Mode to the Overkiz STOP Preset - if hvac_mode == HVACMode.OFF: - await self.executor.async_execute_command( - OverkizCommand.SET_COOLING_ON_OFF, - OverkizCommandParam.OFF, - ) - await self.executor.async_execute_command( - OverkizCommand.SET_HEATING_ON_OFF, - OverkizCommandParam.OFF, - ) - else: - await self.executor.async_execute_command( - OverkizCommand.SET_COOLING_ON_OFF, - OverkizCommandParam.ON, - ) - await self.executor.async_execute_command( - OverkizCommand.SET_HEATING_ON_OFF, - OverkizCommandParam.ON, - ) + on_off_target_command_param = ( + OverkizCommandParam.OFF + if hvac_mode == HVACMode.OFF + else OverkizCommandParam.ON + ) + + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + on_off_target_command_param, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + on_off_target_command_param, + ) await self.async_refresh_modes() @property - def preset_mode(self) -> str: + def preset_mode(self) -> str | None: """Return the current preset mode, e.g., schedule, manual.""" if self.is_using_derogated_temperature_fallback: return super().preset_mode - mode = OVERKIZ_MODE_TO_PRESET_MODES[ - cast( - str, - self.executor.select_state( - OverkizState.IO_PASS_APC_COOLING_MODE - if self.zone_control_hvac_mode == HVACMode.COOL - else OverkizState.IO_PASS_APC_HEATING_MODE - ), + if ( + self.zone_control_hvac_action in HVAC_ACTION_TO_OVERKIZ_MODE_STATE + and ( + mode_state := HVAC_ACTION_TO_OVERKIZ_MODE_STATE[ + self.zone_control_hvac_action + ] + ) + and ( + ( + mode := OVERKIZ_MODE_TO_PRESET_MODES[ + cast(str, self.executor.select_state(mode_state)) + ] + ) + is not None ) - ] + ): + return mode - return mode if mode is not None else PRESET_NONE + return PRESET_NONE async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -178,13 +302,18 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self.async_refresh_modes() @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return hvac target temperature.""" if self.is_using_derogated_temperature_fallback: return super().target_temperature - if self.zone_control_hvac_mode == HVACMode.COOL: + device_hvac_mode = self.device_hvac_mode + + if device_hvac_mode == HVACMode.HEAT_COOL: + return None + + if device_hvac_mode == HVACMode.COOL: return cast( float, self.executor.select_state( @@ -192,7 +321,7 @@ def target_temperature(self) -> float: ), ) - if self.zone_control_hvac_mode == HVACMode.HEAT: + if device_hvac_mode == HVACMode.HEAT: return cast( float, self.executor.select_state( @@ -204,32 +333,73 @@ def target_temperature(self) -> float: float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) ) + @property + def target_temperature_high(self) -> float | None: + """Return the highbound target temperature we try to reach (cooling).""" + + if self.device_hvac_mode != HVACMode.HEAT_COOL: + return None + + return cast( + float, + self.executor.select_state(OverkizState.CORE_COOLING_TARGET_TEMPERATURE), + ) + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach (heating).""" + + if self.device_hvac_mode != HVACMode.HEAT_COOL: + return None + + return cast( + float, + self.executor.select_state(OverkizState.CORE_HEATING_TARGET_TEMPERATURE), + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new temperature.""" if self.is_using_derogated_temperature_fallback: return await super().async_set_temperature(**kwargs) - temperature = kwargs[ATTR_TEMPERATURE] + target_temperature = kwargs.get(ATTR_TEMPERATURE) + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + hvac_mode = self.hvac_mode + + if hvac_mode == HVACMode.HEAT_COOL: + if target_temp_low is not None: + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, + target_temp_low, + ) + + if target_temp_high is not None: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, + target_temp_high, + ) + + elif target_temperature is not None: + if hvac_mode == HVACMode.HEAT: + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, + target_temperature, + ) + + elif hvac_mode == HVACMode.COOL: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, + target_temperature, + ) - # Change both (heating/cooling) temperature is a good way to have consistency - await self.executor.async_execute_command( - OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, - temperature, - ) - await self.executor.async_execute_command( - OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, - temperature, - ) await self.executor.async_execute_command( OverkizCommand.SET_DEROGATION_ON_OFF_STATE, - OverkizCommandParam.OFF, + OverkizCommandParam.ON, ) - # Target temperature may take up to 1 minute to get refreshed. - await self.executor.async_execute_command( - OverkizCommand.REFRESH_TARGET_TEMPERATURE - ) + await self.async_refresh_modes() async def async_refresh_modes(self) -> None: """Refresh the device modes to have new states.""" @@ -256,3 +426,51 @@ async def async_refresh_modes(self) -> None: await self.executor.async_execute_command( OverkizCommand.REFRESH_TARGET_TEMPERATURE ) + + @property + def min_temp(self) -> float: + """Return Minimum Temperature for AC of this group.""" + + device_hvac_mode = self.device_hvac_mode + + if device_hvac_mode in (HVACMode.HEAT, HVACMode.HEAT_COOL): + return cast( + float, + self.executor.select_state( + OverkizState.CORE_MINIMUM_HEATING_TARGET_TEMPERATURE + ), + ) + + if device_hvac_mode == HVACMode.COOL: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_MINIMUM_COOLING_TARGET_TEMPERATURE + ), + ) + + return super().min_temp + + @property + def max_temp(self) -> float: + """Return Max Temperature for AC of this group.""" + + device_hvac_mode = self.device_hvac_mode + + if device_hvac_mode == HVACMode.HEAT: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_MAXIMUM_HEATING_TARGET_TEMPERATURE + ), + ) + + if device_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL): + return cast( + float, + self.executor.select_state( + OverkizState.CORE_MAXIMUM_COOLING_TARGET_TEMPERATURE + ), + ) + + return super().max_temp diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py index 86cde4fc4db29f..b4d6ab788a12b5 100644 --- a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py @@ -357,5 +357,5 @@ async def _global_control( ] await self.executor.async_execute_command( - OverkizCommand.GLOBAL_CONTROL, command_data + OverkizCommand.GLOBAL_CONTROL, *command_data ) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index db24a299f2a95c..2ef0f0ebef4790 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -19,7 +19,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.8"], + "requirements": ["pyoverkiz==1.13.9"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index f81ed82f7b16cf..494d430c39349c 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -97,6 +97,28 @@ async def _async_set_native_value_boost_mode_duration( max_value_state_name=OverkizState.CORE_MAXIMAL_SHOWER_MANUAL_MODE, entity_category=EntityCategory.CONFIG, ), + OverkizNumberDescription( + key=OverkizState.CORE_TARGET_DWH_TEMPERATURE, + name="Target temperature", + device_class=NumberDeviceClass.TEMPERATURE, + command=OverkizCommand.SET_TARGET_DHW_TEMPERATURE, + native_min_value=50, + native_max_value=65, + min_value_state_name=OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE, + max_value_state_name=OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE, + entity_category=EntityCategory.CONFIG, + ), + OverkizNumberDescription( + key=OverkizState.CORE_WATER_TARGET_TEMPERATURE, + name="Water target temperature", + device_class=NumberDeviceClass.TEMPERATURE, + command=OverkizCommand.SET_WATER_TARGET_TEMPERATURE, + native_min_value=50, + native_max_value=65, + min_value_state_name=OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE, + max_value_state_name=OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE, + entity_category=EntityCategory.CONFIG, + ), # SomfyHeatingTemperatureInterface OverkizNumberDescription( key=OverkizState.CORE_ECO_ROOM_TEMPERATURE, diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 2b0a222f96f113..c62840eea97df4 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -399,6 +399,20 @@ class OverkizSensorDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfTime.SECONDS, entity_category=EntityCategory.DIAGNOSTIC, ), + OverkizSensorDescription( + key=OverkizState.CORE_BOTTOM_TANK_WATER_TEMPERATURE, + name="Bottom tank water temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + OverkizSensorDescription( + key=OverkizState.CORE_CONTROL_WATER_TARGET_TEMPERATURE, + name="Control water target temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), # Cover OverkizSensorDescription( key=OverkizState.CORE_TARGET_CLOSURE, diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 3e669079848f17..011b4f75489cc9 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -135,8 +135,6 @@ def _decrypt_payload(secret, topic, ciphertext): try: message = decrypt(ciphertext, key) message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message except ValueError: _LOGGER.warning( ( @@ -146,6 +144,8 @@ def _decrypt_payload(secret, topic, ciphertext): topic, ) return None + _LOGGER.debug("Decrypted payload: %s", message) + return message def encrypt_message(secret, topic, message): diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 8ec825c59742fe..5c76a7e6900f75 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -247,9 +247,6 @@ async def _handle_errors(self, func, *args): """Handle errors from func, set available and reconnect if needed.""" try: result = await self._hass.async_add_executor_job(func, *args) - self.state = STATE_ON - self.available = True - return result except EncryptionRequired: _LOGGER.error( "The connection couldn't be encrypted. Please reconfigure your TV" @@ -260,12 +257,18 @@ async def _handle_errors(self, func, *args): self.state = STATE_OFF self.available = True await self.async_create_remote_control() + return None except (URLError, OSError) as err: _LOGGER.debug("An error occurred: %s", err) self.state = STATE_OFF self.available = self._on_action is not None await self.async_create_remote_control() + return None except Exception: # pylint: disable=broad-except _LOGGER.exception("An unknown error occurred") self.state = STATE_OFF self.available = self._on_action is not None + return None + self.state = STATE_ON + self.available = True + return result diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 7a76d3174cdef4..c367d5ec548049 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -50,14 +50,14 @@ async def _async_update_data(self) -> dict: # Update the auth token in the config entry if applicable self._update_auth_token() - - # Return the fetched data - return data except ValueError as error: raise UpdateFailed(f"API response was malformed: {error}") from error except PicnicAuthError as error: raise ConfigEntryAuthFailed from error + # Return the fetched data + return data + def fetch_data(self): """Fetch the data from the Picnic API and return a flat dict with only needed sensor data.""" # Fetch from the API and pre-process the data diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index f9afcef7be9a6c..f1fd8518d42262 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -140,7 +140,6 @@ async def async_ping(self) -> dict[str, Any] | None: if TYPE_CHECKING: assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} except TimeoutError: _LOGGER.exception( "Timed out running command: `%s`, after: %ss", @@ -155,6 +154,7 @@ async def async_ping(self) -> dict[str, Any] | None: return None except AttributeError: return None + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} async def async_update(self) -> None: """Retrieve the latest details from the host.""" diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index 36d1ae9d10f77b..911ae6104fdc7f 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -35,11 +35,11 @@ async def validate_input(hass: HomeAssistant, data): auth = Auth(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY]) try: contracts = await Installation.list(auth) - return auth, contracts except ConnectionRefusedError: raise InvalidAuth from ConnectionRefusedError except ConnectionError: raise CannotConnect from ConnectionError + return auth, contracts class ProsegurConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 604b029fc923c9..e8d357726bc3ca 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -146,15 +146,19 @@ class PrusaLinkSensorEntityDescription( translation_key="progress", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: cast(float, data["progress"]), - available_fn=lambda data: data.get("progress") is not None - and data.get("state") != PrinterState.IDLE.value, + available_fn=lambda data: ( + data.get("progress") is not None + and data.get("state") != PrinterState.IDLE.value + ), ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", value_fn=lambda data: cast(str, data["file"]["display_name"]), - available_fn=lambda data: data.get("file") is not None - and data.get("state") != PrinterState.IDLE.value, + available_fn=lambda data: ( + data.get("file") is not None + and data.get("state") != PrinterState.IDLE.value + ), ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", @@ -164,8 +168,10 @@ class PrusaLinkSensorEntityDescription( lambda data: (utcnow() - timedelta(seconds=data["time_printing"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("time_printing") is not None - and data.get("state") != PrinterState.IDLE.value, + available_fn=lambda data: ( + data.get("time_printing") is not None + and data.get("state") != PrinterState.IDLE.value + ), ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", @@ -175,8 +181,10 @@ class PrusaLinkSensorEntityDescription( lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("time_remaining") is not None - and data.get("state") != PrinterState.IDLE.value, + available_fn=lambda data: ( + data.get("time_remaining") is not None + and data.get("state") != PrinterState.IDLE.value + ), ), ), } diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py index 544ef808ca747b..2754ab3a1ec211 100644 --- a/homeassistant/components/qvr_pro/camera.py +++ b/homeassistant/components/qvr_pro/camera.py @@ -43,19 +43,18 @@ def get_stream_source(guid, client): """Get channel stream source.""" try: resp = client.get_channel_live_stream(guid, protocol="rtsp") - - full_url = resp["resourceUris"] - - protocol = full_url[:7] - auth = f"{client.get_auth_string()}@" - url = full_url[7:] - - return f"{protocol}{auth}{url}" - except QVRResponseError as ex: _LOGGER.error(ex) return None + full_url = resp["resourceUris"] + + protocol = full_url[:7] + auth = f"{client.get_auth_string()}@" + url = full_url[7:] + + return f"{protocol}{auth}{url}" + class QVRProCamera(Camera): """Representation of a QVR Pro camera.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3268bae4d49d0b..4ae61a0c4bae6e 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1179,7 +1179,6 @@ def _commit_event_session_or_retry(self) -> None: while tries <= self.db_max_retries: try: self._commit_event_session() - return except (exc.InternalError, exc.OperationalError) as err: _LOGGER.error( "%s: Error executing query: %s. (retrying in %s seconds)", @@ -1192,6 +1191,8 @@ def _commit_event_session_or_retry(self) -> None: tries += 1 time.sleep(self.db_retry_wait) + else: + return def _commit_event_session(self) -> None: assert self.event_session is not None diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0d882ed3b66277..630628b204599c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -341,10 +341,10 @@ def _execute_or_collect_error( with session_scope(session=session_maker()) as session: try: session.connection().execute(text(query)) - return True except SQLAlchemyError as err: errors.append(str(err)) - return False + return False + return True def _drop_index( @@ -439,11 +439,12 @@ def _add_columns( ) ) ) - return except (InternalError, OperationalError, ProgrammingError): # Some engines support adding all columns at once, # this error is when they don't _LOGGER.info("Unable to use quick column add. Adding 1 by 1") + else: + return for column_def in columns_def: with session_scope(session=session_maker()) as session: @@ -510,9 +511,10 @@ def _modify_columns( ) ) ) - return except (InternalError, OperationalError): _LOGGER.info("Unable to use quick column modify. Modifying 1 by 1") + else: + return for column_def in columns_def: with session_scope(session=session_maker()) as session: diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 27bc313b162d68..ec7aa5bdcb6f37 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -14,7 +14,7 @@ ) from homeassistant.helpers.frame import report -from homeassistant.util.async_ import check_loop +from homeassistant.util.loop import check_loop from .const import DB_WORKER_PREFIX diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index f840fdbd7b6bb0..0c127d079add90 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -641,7 +641,6 @@ def _insert_statistics( try: stat = table.from_stats(metadata_id, statistic) session.add(stat) - return stat except SQLAlchemyError: _LOGGER.exception( "Unexpected exception when inserting statistics %s:%s ", @@ -649,6 +648,7 @@ def _insert_statistics( statistic, ) return None + return stat def _update_statistics( diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 77467ec1171d0b..ad96833b1d7b7d 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -192,13 +192,14 @@ def execute( elapsed, ) - return result except SQLAlchemyError as err: _LOGGER.error("Error executing query: %s", err) if tryno == RETRIES - 1: raise time.sleep(QUERY_RETRY_WAIT) + else: + return result # Unreachable raise RuntimeError # pragma: no cover @@ -685,7 +686,6 @@ def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: for attempt in range(attempts): try: job(instance, *args, **kwargs) - return except OperationalError as err: if attempt == attempts - 1 or not _is_retryable_error( instance, err @@ -697,6 +697,8 @@ def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: ) time.sleep(instance.db_retry_wait) # Failed with retryable error + else: + return return wrapper diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index f77a38f2505cee..d7aed6e35603c1 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -55,8 +55,6 @@ async def _async_update_data(self) -> T: try: async with _PARALLEL_SEMAPHORE: data = await self.update_method() - self._has_already_worked = True - return data except AccessDeniedException as err: # This can mean both a temporary error or a permanent error. If it has @@ -76,6 +74,9 @@ async def _async_update_data(self) -> T: # Other Renault errors. raise UpdateFailed(f"Error communicating with API: {err}") from err + self._has_already_worked = True + return data + async def async_config_entry_first_refresh(self) -> None: """Refresh data for the first time when a config entry is setup. diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 55a5574a444a67..59e1826ce1b85b 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -125,16 +125,16 @@ async def async_initialise(self) -> None: coordinator = self.coordinators[key] if coordinator.not_supported: # Remove endpoint as it is not supported for this vehicle. - LOGGER.info( - "Ignoring endpoint %s as it is not supported for this vehicle: %s", + LOGGER.warning( + "Ignoring endpoint %s as it is not supported: %s", coordinator.name, coordinator.last_exception, ) del self.coordinators[key] elif coordinator.access_denied: # Remove endpoint as it is denied for this vehicle. - LOGGER.info( - "Ignoring endpoint %s as it is denied for this vehicle: %s", + LOGGER.warning( + "Ignoring endpoint %s as it is denied: %s", coordinator.name, coordinator.last_exception, ) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 0390db640e5ad4..764557a3a1de49 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.7"] + "requirements": ["ring-doorbell[listen]==0.8.8"] } diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index baef00b25962c4..303d0e91a36056 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -62,10 +62,10 @@ async def _async_update_data(self) -> Device: try: data = await self.roku.update(full_update=full_update) - - if full_update: - self.last_full_update = utcnow() - - return data except RokuError as error: raise UpdateFailed(f"Invalid response from API: {error}") from error + + if full_update: + self.last_full_update = utcnow() + + return data diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index f2e5162a0f0662..19f16005578bec 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -39,10 +39,10 @@ async def _async_update_data(self) -> dict[int, dict]: meter["consumption_forecast"] = await self.rympro.consumption_forecast( meter_id ) - return meters except UnauthorizedError as error: assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) raise UpdateFailed(error) from error except (CannotConnectError, OperationError) as error: raise UpdateFailed(error) from error + return meters diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 00b8fec8e6acb4..460e191828efa0 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.38.2" + "async-upnp-client==0.38.3" ], "ssdp": [ { diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 736654fc399242..95bbb01bcfbed3 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -135,12 +135,12 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: service_response["stdout"] = stdout_data.decode("utf-8").strip() if stderr_data: service_response["stderr"] = stderr_data.decode("utf-8").strip() - return service_response except UnicodeDecodeError: _LOGGER.exception( "Unable to handle non-utf8 output of command: `%s`", cmd ) raise + return service_response return None for name in conf: diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 3580bcf9b38bda..2ac0416bb6c311 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -234,3 +234,5 @@ class BLEScannerMode(StrEnum): ) CONF_GEN = "gen" + +SHELLY_PLUS_RGBW_CHANNELS = 4 diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 6c28023a5e3375..d0590fc7c20841 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -14,6 +14,7 @@ ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_TRANSITION, + DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, @@ -34,12 +35,14 @@ RGBW_MODELS, RPC_MIN_TRANSITION_TIME_SEC, SHBLB_1_RGB_EFFECTS, + SHELLY_PLUS_RGBW_CHANNELS, STANDARD_RGB_EFFECTS, ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyBlockEntity, ShellyRpcEntity from .utils import ( async_remove_shelly_entity, + async_remove_shelly_rpc_entities, brightness_to_percentage, get_device_entry_gen, get_rpc_key_ids, @@ -118,14 +121,28 @@ def async_setup_rpc_entry( return if light_key_ids := get_rpc_key_ids(coordinator.device.status, "light"): + # Light mode remove RGB & RGBW entities, add light entities + async_remove_shelly_rpc_entities( + hass, LIGHT_DOMAIN, coordinator.mac, ["rgb:0", "rgbw:0"] + ) async_add_entities(RpcShellyLight(coordinator, id_) for id_ in light_key_ids) return + light_keys = [f"light:{i}" for i in range(SHELLY_PLUS_RGBW_CHANNELS)] + if rgb_key_ids := get_rpc_key_ids(coordinator.device.status, "rgb"): + # RGB mode remove light & RGBW entities, add RGB entity + async_remove_shelly_rpc_entities( + hass, LIGHT_DOMAIN, coordinator.mac, [*light_keys, "rgbw:0"] + ) 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"): + # RGBW mode remove light & RGB entities, add RGBW entity + async_remove_shelly_rpc_entities( + hass, LIGHT_DOMAIN, coordinator.mac, [*light_keys, "rgb:0"] + ) async_add_entities(RpcShellyRgbwLight(coordinator, id_) for id_ in rgbw_key_ids) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d26e3dc11f3015..ce98e0d5c12d8d 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -488,3 +488,15 @@ async def async_shutdown_device(device: BlockDevice | RpcDevice) -> None: await device.shutdown() if isinstance(device, BlockDevice): device.shutdown() + + +@callback +def async_remove_shelly_rpc_entities( + hass: HomeAssistant, domain: str, mac: str, keys: list[str] +) -> None: + """Remove RPC based Shelly entity.""" + entity_reg = er_async_get(hass) + for key in keys: + if entity_id := entity_reg.async_get_entity_id(domain, DOMAIN, f"{mac}-{key}"): + LOGGER.debug("Removing entity: %s", entity_id) + entity_reg.async_remove(entity_id) diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 50abc9b39ef187..9b5b8c1f51e323 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -210,7 +210,7 @@ async def create_sms_gateway(config, hass): _LOGGER.error("Failed to initialize, error %s", exc) await gateway.terminate_async() return None - return gateway except gammu.GSMError as exc: _LOGGER.error("Failed to create async worker, error %s", exc) return None + return gateway diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 83b9c600de8cbe..40343b5ac12b24 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -44,14 +44,14 @@ async def _test_connection(self, host): """Check if we can connect to the Solar-Log device.""" try: await self.hass.async_add_executor_job(SolarLog, host) - return True except (OSError, HTTPError, Timeout): self._errors[CONF_HOST] = "cannot_connect" _LOGGER.error( "Could not connect to Solar-Log device at %s, check host ip address", host, ) - return False + return False + return True async def async_step_user(self, user_input=None): """Step when user initializes a integration.""" diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json index d4d33a77d43fc5..c4dec6b938da44 100644 --- a/homeassistant/components/songpal/manifest.json +++ b/homeassistant/components/songpal/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["songpal"], "quality_scale": "gold", - "requirements": ["python-songpal==0.16.1"], + "requirements": ["python-songpal==0.16.2"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 831e66d1c4e36d..3c15f2fb820eaf 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -25,10 +25,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b partial(speedtest.Speedtest, secure=True) ) coordinator = SpeedTestDataCoordinator(hass, config_entry, api) - await hass.async_add_executor_job(coordinator.update_servers) except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err + hass.data[DOMAIN] = coordinator + async def _async_finish_startup(hass: HomeAssistant) -> None: """Run this only when HA has finished its startup.""" await coordinator.async_config_entry_first_refresh() @@ -36,8 +37,6 @@ async def _async_finish_startup(hass: HomeAssistant) -> None: # Don't start a speedtest during startup async_at_started(hass, _async_finish_startup) - hass.data[DOMAIN] = coordinator - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 487e58d8f8b39c..2e725e8d1392c3 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -101,8 +101,6 @@ def wrapper( # pylint: disable=protected-access try: result = func(self, *args, **kwargs) - self._attr_available = True - return result except requests.RequestException: self._attr_available = False return None @@ -111,6 +109,8 @@ def wrapper( if exc.reason == "NO_ACTIVE_DEVICE": raise HomeAssistantError("No active playback device found") from None raise HomeAssistantError(f"Spotify error: {exc.reason}") from exc + self._attr_available = True + return result return wrapper diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b34105106e07bb..08d1bbb858ea30 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -735,10 +735,11 @@ async def _async_find_next_available_port(source: AddressTupleVXType) -> int: addr = (source[0],) + (port,) + source[2:] try: test_socket.bind(addr) - return port except OSError: if port == UPNP_SERVER_MAX_PORT - 1: raise + else: + return port raise RuntimeError("unreachable") diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index a9ef8af8c909d0..5e549c31806877 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.38.2"] + "requirements": ["async-upnp-client==0.38.3"] } diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 07944de2c81786..f5b2880e0117bb 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -28,9 +28,9 @@ def get_client() -> SuezClient: ) if not client.check_credentials(): raise ConfigEntryError - return client except PySuezError as ex: raise ConfigEntryNotReady from ex + return client hass.data.setdefault(DOMAIN, {})[ entry.entry_id diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 6a1e48304436d3..f61745a1407e8b 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -235,11 +235,12 @@ async def async_check_can_reach_url( try: await session.get(url, timeout=5) - return "ok" except aiohttp.ClientError: data = {"type": "failed", "error": "unreachable"} except TimeoutError: data = {"type": "failed", "error": "timeout"} + else: + return "ok" if more_info is not None: data["more_info"] = more_info return data diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index 46520134bf6481..d6a7fb28f11ac1 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -149,7 +149,6 @@ def update(self) -> None: if value_template is not None: try: self._state = value_template.render(parse_result=False, value=value) - return except TemplateError: _LOGGER.error( "Unable to render template of %r with value: %r", @@ -157,5 +156,6 @@ def update(self) -> None: value, ) return + return self._state = value diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 6338996256b219..4e47be8b807b20 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -687,11 +687,12 @@ async def _send_msg( _LOGGER.warning( "Update last message: out_type:%s, out=%s", type(out), out ) - return out except TelegramError as exc: _LOGGER.error( "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg ) + return None + return out async def send_message(self, message="", target=None, **kwargs): """Send a message to one or multiple pre-allowed chat IDs.""" diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 4dcc4d41950e0f..d7d6ae4ea0c79f 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -267,9 +267,6 @@ async def get_api( path=path, ) ) - _LOGGER.debug("Successfully connected to %s", host) - return api - except TransmissionAuthError as error: _LOGGER.error("Credentials for Transmission client are not valid") raise AuthenticationError from error @@ -279,3 +276,5 @@ async def get_api( except TransmissionError as error: _LOGGER.error(error) raise UnknownError from error + _LOGGER.debug("Successfully connected to %s", host) + return api diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 45fc76c73df6fd..86c38a5bf3db82 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -7,12 +7,14 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass +import secrets from typing import Any import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.devices import Devices from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.device import ( Device, @@ -20,6 +22,7 @@ DeviceRestartRequest, ) from aiounifi.models.port import Port +from aiounifi.models.wlan import Wlan, WlanChangePasswordRequest from homeassistant.components.button import ( ButtonDeviceClass, @@ -37,6 +40,8 @@ UnifiEntityDescription, async_device_available_fn, async_device_device_info_fn, + async_wlan_available_fn, + async_wlan_device_info_fn, ) from .hub import UnifiHub @@ -56,6 +61,15 @@ async def async_power_cycle_port_control_fn( await api.request(DevicePowerCyclePortRequest.create(mac, int(index))) +async def async_regenerate_password_control_fn( + api: aiounifi.Controller, obj_id: str +) -> None: + """Regenerate WLAN password.""" + await api.request( + WlanChangePasswordRequest.create(obj_id, secrets.token_urlsafe(15)) + ) + + @dataclass(frozen=True, kw_only=True) class UnifiButtonEntityDescription( ButtonEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] @@ -91,6 +105,19 @@ class UnifiButtonEntityDescription( supported_fn=lambda hub, obj_id: bool(hub.api.ports[obj_id].port_poe), unique_id_fn=lambda hub, obj_id: f"power_cycle-{obj_id}", ), + UnifiButtonEntityDescription[Wlans, Wlan]( + key="WLAN regenerate password", + device_class=ButtonDeviceClass.UPDATE, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.wlans, + available_fn=async_wlan_available_fn, + control_fn=async_regenerate_password_control_fn, + device_info_fn=async_wlan_device_info_fn, + name_fn=lambda wlan: "Regenerate Password", + object_fn=lambda api, obj_id: api.wlans[obj_id], + unique_id_fn=lambda hub, obj_id: f"regenerate_password-{obj_id}", + ), ) @@ -109,7 +136,7 @@ async def async_setup_entry( class UnifiButtonEntity(UnifiEntity[HandlerT, ApiItemT], ButtonEntity): - """Base representation of a UniFi image.""" + """Base representation of a UniFi button.""" entity_description: UnifiButtonEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py index 8a1be0427b27de..acdd941dd15e15 100644 --- a/homeassistant/components/unifi/hub/api.py +++ b/homeassistant/components/unifi/hub/api.py @@ -56,7 +56,6 @@ async def get_unifi_api( try: async with asyncio.timeout(10): await api.login() - return api except aiounifi.Unauthorized as err: LOGGER.warning( @@ -90,3 +89,5 @@ async def get_unifi_api( except aiounifi.AiounifiException as err: LOGGER.exception("Unknown UniFi Network communication error occurred: %s", err) raise AuthenticationRequired from err + + return api diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index efb3eed4de405f..54ecc2ea763209 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -339,6 +339,19 @@ class UnifiSensorEntityDescription( value_fn=async_device_state_value_fn, options=list(DEVICE_STATES.values()), ), + UnifiSensorEntityDescription[Wlans, Wlan]( + key="WLAN password", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.wlans, + available_fn=async_wlan_available_fn, + device_info_fn=async_wlan_device_info_fn, + name_fn=lambda wlan: "Password", + object_fn=lambda api, obj_id: api.wlans[obj_id], + supported_fn=lambda hub, obj_id: hub.api.wlans[obj_id].x_passphrase is not None, + unique_id_fn=lambda hub, obj_id: f"password-{obj_id}", + value_fn=lambda hub, obj: obj.x_passphrase, + ), ) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index f274da6f41219d..142c9b4a6c3220 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -408,10 +408,10 @@ def state(self) -> str | None: try: newer = _version_is_newer(latest_version, installed_version) - return STATE_ON if newer else STATE_OFF except AwesomeVersionCompareException: # Can't compare versions, already tried exact match return STATE_ON + return STATE_ON if newer else STATE_OFF @final @property diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index edfde84a2ac5a8..7d353a475c77d4 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.3", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 7c9234d35c239d..b8e94e9dfb76d0 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -175,11 +175,10 @@ async def async_set_profile_fan_speed_home( try: await self._client.set_fan_speed(Profile.HOME, fan_speed) - return True - except ValloxApiException as err: _LOGGER.error("Error setting fan speed for Home profile: %s", err) return False + return True async def async_set_profile_fan_speed_away( self, fan_speed: int = DEFAULT_FAN_SPEED_AWAY @@ -189,11 +188,10 @@ async def async_set_profile_fan_speed_away( try: await self._client.set_fan_speed(Profile.AWAY, fan_speed) - return True - except ValloxApiException as err: _LOGGER.error("Error setting fan speed for Away profile: %s", err) return False + return True async def async_set_profile_fan_speed_boost( self, fan_speed: int = DEFAULT_FAN_SPEED_BOOST @@ -203,11 +201,10 @@ async def async_set_profile_fan_speed_boost( try: await self._client.set_fan_speed(Profile.BOOST, fan_speed) - return True - except ValloxApiException as err: _LOGGER.error("Error setting fan speed for Boost profile: %s", err) return False + return True async def async_handle(self, call: ServiceCall) -> None: """Dispatch a service call.""" diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 3738b0f956a996..9c6c6bca422fb6 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -84,11 +84,13 @@ async def async_http_request(hass, uri): if req.status != HTTPStatus.OK: return {"error": req.status} json_response = await req.json() - return json_response except (TimeoutError, aiohttp.ClientError) as exc: _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) + return None except ValueError: _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint") + return None + return json_response class ViaggiaTrenoSensor(SensorEntity): diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 2019f28a896179..2ba5ddbfb0ab97 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -35,13 +35,14 @@ def is_supported( """Check if the PyViCare device supports the requested sensor.""" try: entity_description.value_getter(vicare_device) - _LOGGER.debug("Found entity %s", name) - return True except PyViCareNotSupportedFeatureError: _LOGGER.debug("Feature not supported %s", name) + return False except AttributeError as error: _LOGGER.debug("Feature not supported %s: %s", name, error) - return False + return False + _LOGGER.debug("Found entity %s", name) + return True def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index f2d1416e2c9bde..0076c85e2687b9 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -178,10 +178,10 @@ async def async_handle_webhook( response: Response | None = await webhook["handler"](hass, webhook_id, request) if response is None: response = Response(status=HTTPStatus.OK) - return response except Exception: # pylint: disable=broad-except _LOGGER.exception("Error processing webhook %s", webhook_id) return Response(status=HTTPStatus.OK) + return response async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 6b1ac2a77213b5..79c317f178b433 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -94,9 +94,9 @@ async def _async_update() -> float | None: if bulb.power_monitoring is not False: power: float | None = await bulb.get_power() return power - return None except WIZ_EXCEPTIONS as ex: raise UpdateFailed(f"Failed to update device at {ip_address}: {ex}") from ex + return None coordinator = DataUpdateCoordinator( hass=hass, diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 195221ef088a1f..077a6710b8d9b8 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from functools import partial + from holidays import HolidayBase, country_holidays from homeassistant.config_entries import ConfigEntry @@ -13,7 +15,7 @@ from .const import CONF_PROVINCE, DOMAIN, PLATFORMS -def _validate_country_and_province( +async def _async_validate_country_and_province( hass: HomeAssistant, entry: ConfigEntry, country: str | None, province: str | None ) -> None: """Validate country and province.""" @@ -21,7 +23,7 @@ def _validate_country_and_province( if not country: return try: - country_holidays(country) + await hass.async_add_executor_job(country_holidays, country) except NotImplementedError as ex: async_create_issue( hass, @@ -39,7 +41,9 @@ def _validate_country_and_province( if not province: return try: - country_holidays(country, subdiv=province) + await hass.async_add_executor_job( + partial(country_holidays, country, subdiv=province) + ) except NotImplementedError as ex: async_create_issue( hass, @@ -66,10 +70,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: country: str | None = entry.options.get(CONF_COUNTRY) province: str | None = entry.options.get(CONF_PROVINCE) - _validate_country_and_province(hass, entry, country, province) + await _async_validate_country_and_province(hass, entry, country, province) if country and CONF_LANGUAGE not in entry.options: - cls: HolidayBase = country_holidays(country, subdiv=province) + cls: HolidayBase = await hass.async_add_executor_job( + partial(country_holidays, country, subdiv=province) + ) default_language = cls.default_language new_options = entry.options.copy() new_options[CONF_LANGUAGE] = default_language diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index 2f5e6e299e9535..39cb0ee5f9696b 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -150,16 +150,15 @@ async def _try_command(self, mask_error, func, *args, **kwargs): result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs) ) - - _LOGGER.debug("Response received from miio device: %s", result) - - return True except DeviceException as exc: if self.available: _LOGGER.error(mask_error, exc) return False + _LOGGER.debug("Response received from miio device: %s", result) + return True + @classmethod def _extract_value_from_attribute(cls, state, attribute): value = getattr(state, attribute) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index cbbf12f9ab16a3..96f9595e0e8ecb 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -292,10 +292,6 @@ async def _try_command(self, mask_error, func, *args, **kwargs): result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs) ) - - _LOGGER.debug("Response received from light: %s", result) - - return result == SUCCESS except DeviceException as exc: if self._available: _LOGGER.error(mask_error, exc) @@ -303,6 +299,9 @@ async def _try_command(self, mask_error, func, *args, **kwargs): return False + _LOGGER.debug("Response received from light: %s", result) + return result == SUCCESS + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index c1234b77bbc18b..cd3b3192520eaf 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -225,9 +225,9 @@ def is_on(self): """Return False if device is unreachable, else True.""" try: self.device.info() - return True except DeviceException: return False + return True async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 02517d00c57510..34ebb9addf51c4 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -805,14 +805,6 @@ async def _try_command(self, mask_error, func, *args, **kwargs): result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs) ) - - _LOGGER.debug("Response received from plug: %s", result) - - # The Chuangmi Plug V3 returns 0 on success on usb_on/usb_off. - if func in ["usb_on", "usb_off"] and result == 0: - return True - - return result == SUCCESS except DeviceException as exc: if self._available: _LOGGER.error(mask_error, exc) @@ -820,6 +812,14 @@ async def _try_command(self, mask_error, func, *args, **kwargs): return False + _LOGGER.debug("Response received from plug: %s", result) + + # The Chuangmi Plug V3 returns 0 on success on usb_on/usb_off. + if func in ["usb_on", "usb_off"] and result == 0: + return True + + return result == SUCCESS + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the plug on.""" result = await self._try_command("Turning the plug on failed", self._device.on) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 41f2c2386e1110..ef6f94c162fa06 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -281,10 +281,10 @@ async def _try_command(self, mask_error, func, *args, **kwargs): try: await self.hass.async_add_executor_job(partial(func, *args, **kwargs)) await self.coordinator.async_refresh() - return True except DeviceException as exc: _LOGGER.error(mask_error, exc) return False + return True async def async_start(self) -> None: """Start or resume the cleaning task.""" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 20f8ed3ed4d2bc..e9f304d38cb711 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.3"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 241b2232eeb21d..286a6460f19a25 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -66,9 +66,9 @@ def native_temperature(self) -> float | None: value := self.coordinator.data[self.station_id]["TL"]["data"] ) is not None: return float(value) - return None except (KeyError, ValueError, TypeError): return None + return None @property def native_pressure(self) -> float | None: @@ -98,9 +98,9 @@ def native_wind_speed(self) -> float | None: value := self.coordinator.data[self.station_id]["FFX"]["data"] ) is not None: return float(value) - return None except (KeyError, ValueError, TypeError): return None + return None @property def wind_bearing(self) -> float | None: @@ -114,6 +114,6 @@ def wind_bearing(self) -> float | None: value := self.coordinator.data[self.station_id]["DDX"]["data"] ) is not None: return float(value) - return None except (KeyError, ValueError, TypeError): return None + return None diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e6d9f3e66b59eb..e5fdfe36a9bf5c 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -789,15 +789,6 @@ async def write_zigbee_attribute( response = await cluster.write_attributes( {attribute: value}, manufacturer=manufacturer ) - self.debug( - "set: %s for attr: %s to cluster: %s for ept: %s - res: %s", - value, - attribute, - cluster_id, - endpoint_id, - response, - ) - return response except zigpy.exceptions.ZigbeeException as exc: raise HomeAssistantError( f"Failed to set attribute: " @@ -807,6 +798,16 @@ async def write_zigbee_attribute( f"{ATTR_ENDPOINT_ID}: {endpoint_id}" ) from exc + self.debug( + "set: %s for attr: %s to cluster: %s for ept: %s - res: %s", + value, + attribute, + cluster_id, + endpoint_id, + response, + ) + return response + async def issue_cluster_command( self, endpoint_id: int, diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 1a001cab38111c..b060d56cb049c8 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -102,9 +102,9 @@ async def safe_read( only_cache=only_cache, manufacturer=manufacturer, ) - return result except Exception: # pylint: disable=broad-except return {} + return result async def get_matched_clusters( diff --git a/homeassistant/config.py b/homeassistant/config.py index c570e36c6c1934..d3f30f84a68446 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -464,14 +464,12 @@ def _write_default_config(config_dir: str) -> bool: if not os.path.isfile(scene_yaml_path): with open(scene_yaml_path, "w", encoding="utf8"): pass - - return True - except OSError: print( # noqa: T201 f"Unable to create default configuration file {config_path}" ) return False + return True async def async_hass_config_yaml(hass: HomeAssistant) -> dict: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 890db26810a054..9f5f6b9135ba8a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -763,8 +763,6 @@ async def async_unload( self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) await self._async_process_on_unload(hass) - - return result except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain @@ -774,6 +772,7 @@ async def async_unload( hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) return False + return result async def async_remove(self, hass: HomeAssistant) -> None: """Invoke remove callback on component.""" @@ -872,12 +871,12 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: if result: # pylint: disable-next=protected-access hass.config_entries._async_schedule_save() - return result except Exception: # pylint: disable=broad-except _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain ) return False + return result def add_update_listener(self, listener: UpdateListenerType) -> CALLBACK_TYPE: """Listen for when entry is updated. diff --git a/homeassistant/core.py b/homeassistant/core.py index 76a3f55527c595..58e94d63352d89 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -50,7 +50,7 @@ import voluptuous as vol import yarl -from . import block_async_io, util +from . import util from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -130,7 +130,6 @@ FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 -block_async_io.enable() _T = TypeVar("_T") _R = TypeVar("_R") @@ -774,8 +773,11 @@ def async_add_executor_job( ) -> asyncio.Future[_T]: """Add an executor job from within the event loop.""" task = self.loop.run_in_executor(None, target, *args) - self._tasks.add(task) - task.add_done_callback(self._tasks.remove) + + tracked = asyncio.current_task() in self._tasks + task_bucket = self._tasks if tracked else self._background_tasks + task_bucket.add(task) + task.add_done_callback(task_bucket.remove) return task @@ -783,11 +785,11 @@ def async_add_executor_job( def async_add_import_executor_job( self, target: Callable[[*_Ts], _T], *args: *_Ts ) -> asyncio.Future[_T]: - """Add an import executor job from within the event loop.""" - task = self.loop.run_in_executor(self.import_executor, target, *args) - self._tasks.add(task) - task.add_done_callback(self._tasks.remove) - return task + """Add an import executor job from within the event loop. + + The future returned from this method must be awaited in the event loop. + """ + return self.loop.run_in_executor(self.import_executor, target, *args) @overload @callback @@ -2688,9 +2690,10 @@ def is_allowed_path(self, path: str) -> bool: for allowed_path in self.allowlist_external_dirs: try: thepath.relative_to(allowed_path) - return True except ValueError: pass + else: + return True return False diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 763df8609c4819..b398fd8ba49f8c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -144,6 +144,7 @@ "elvia", "emonitor", "emulated_roku", + "energenie_power_sockets", "energyzero", "enocean", "enphase_envoy", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 891a55617add92..2e5ea5019e77c1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -271,7 +271,8 @@ "name": "Home Assistant Analytics Insights", "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "single_config_entry": true }, "android_ip_webcam": { "name": "Android IP Webcam", @@ -1571,6 +1572,11 @@ "config_flow": true, "iot_class": "local_push" }, + "energenie_power_sockets": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "energie_vanons": { "name": "Energie VanOns", "integration_type": "virtual", @@ -7185,6 +7191,7 @@ "demo", "derivative", "emulated_roku", + "energenie_power_sockets", "filesize", "garages_amsterdam", "generic", diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index ee0c8c1bb88077..fec872623744d9 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -11,6 +11,7 @@ from homeassistant.util.ulid import ulid_now from .registry import BaseRegistry +from .storage import Store from .typing import UNDEFINED, EventType, UndefinedType DATA_REGISTRY = "category_registry" @@ -46,7 +47,8 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize the category registry.""" self.hass = hass self.categories: dict[str, dict[str, CategoryEntry]] = {} - self._store = hass.helpers.storage.Store( + self._store: Store[dict[str, dict[str, list[dict[str, str]]]]] = Store( + hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f7245607be726d..fc39db83658728 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -248,13 +248,13 @@ def is_regex(value: Any) -> re.Pattern[Any]: """Validate that a string is a valid regular expression.""" try: r = re.compile(value) - return r except TypeError as err: raise vol.Invalid( f"value {value} is of the wrong type for a regular expression" ) from err except re.error as err: raise vol.Invalid(f"value {value} is not a valid regular expression") from err + return r def isfile(value: Any) -> str: @@ -671,9 +671,9 @@ def template(value: Any | None) -> template_helper.Template: try: template_value.ensure_valid() - return template_value except TemplateError as ex: raise vol.Invalid(f"invalid template ({ex})") from ex + return template_value def dynamic_template(value: Any | None) -> template_helper.Template: @@ -693,9 +693,9 @@ def dynamic_template(value: Any | None) -> template_helper.Template: try: template_value.ensure_valid() - return template_value except TemplateError as ex: raise vol.Invalid(f"invalid template ({ex})") from ex + return template_value def template_complex(value: Any) -> Any: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 1cff472af72e2f..f605f8381b0672 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -362,10 +362,6 @@ async def _async_setup_platform( pending = self._tasks.copy() self._tasks.clear() await asyncio.gather(*pending) - - hass.config.components.add(full_name) - self._setup_complete = True - return True except PlatformNotReady as ex: tries += 1 wait_time = min(tries, 6) * PLATFORM_NOT_READY_BASE_WAIT_TIME @@ -417,6 +413,10 @@ async def setup_again(*_args: Any) -> None: self.domain, ) return False + else: + hass.config.components.add(full_name) + self._setup_complete = True + return True finally: warn_task.cancel() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ef9274c6ceb5d0..ad9ddcd5c4c0a2 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -243,7 +243,6 @@ def display_json_repr(self) -> bytes | None: try: dict_repr = self._as_display_dict json_repr: bytes | None = json_bytes(dict_repr) if dict_repr else None - return json_repr except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -252,8 +251,8 @@ def display_json_repr(self) -> bytes | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - - return None + return None + return json_repr @cached_property def as_partial_dict(self) -> dict[str, Any]: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 63214cb135b3a2..5fc80bedbed1b5 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -24,7 +24,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from . import area_registry, config_validation as cv, device_registry, entity_registry +from . import ( + area_registry, + config_validation as cv, + device_registry, + entity_registry, + floor_registry, +) _LOGGER = logging.getLogger(__name__) _SlotsType = dict[str, Any] @@ -109,7 +115,6 @@ async def async_handle( try: _LOGGER.info("Triggering intent handler %s", handler) result = await handler.async_handle(intent) - return result except vol.Invalid as err: _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err) raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err @@ -117,6 +122,7 @@ async def async_handle( raise # bubble up intent related errors except Exception as err: raise IntentUnexpectedError(f"Error handling {intent_type}") from err + return result class IntentError(HomeAssistantError): @@ -144,16 +150,18 @@ class NoStatesMatchedError(IntentError): def __init__( self, - name: str | None, - area: str | None, - domains: set[str] | None, - device_classes: set[str] | None, + name: str | None = None, + area: str | None = None, + floor: str | None = None, + domains: set[str] | None = None, + device_classes: set[str] | None = None, ) -> None: """Initialize error.""" super().__init__() self.name = name self.area = area + self.floor = floor self.domains = domains self.device_classes = device_classes @@ -220,12 +228,35 @@ def _find_area( return None -def _filter_by_area( +def _find_floor( + id_or_name: str, floors: floor_registry.FloorRegistry +) -> floor_registry.FloorEntry | None: + """Find an floor by id or name, checking aliases too.""" + floor = floors.async_get_floor(id_or_name) or floors.async_get_floor_by_name( + id_or_name + ) + if floor is not None: + return floor + + # Check floor aliases + for maybe_floor in floors.floors.values(): + if not maybe_floor.aliases: + continue + + for floor_alias in maybe_floor.aliases: + if id_or_name == floor_alias.casefold(): + return maybe_floor + + return None + + +def _filter_by_areas( states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]], - area: area_registry.AreaEntry, + areas: Iterable[area_registry.AreaEntry], devices: device_registry.DeviceRegistry, ) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]: """Filter state/entity pairs by an area.""" + filter_area_ids: set[str | None] = {a.id for a in areas} entity_area_ids: dict[str, str | None] = {} for _state, entity in states_and_entities: if entity is None: @@ -241,7 +272,7 @@ def _filter_by_area( entity_area_ids[entity.id] = device.area_id for state, entity in states_and_entities: - if (entity is not None) and (entity_area_ids.get(entity.id) == area.id): + if (entity is not None) and (entity_area_ids.get(entity.id) in filter_area_ids): yield (state, entity) @@ -252,11 +283,14 @@ def async_match_states( name: str | None = None, area_name: str | None = None, area: area_registry.AreaEntry | None = None, + floor_name: str | None = None, + floor: floor_registry.FloorEntry | None = None, domains: Collection[str] | None = None, device_classes: Collection[str] | None = None, states: Iterable[State] | None = None, entities: entity_registry.EntityRegistry | None = None, areas: area_registry.AreaRegistry | None = None, + floors: floor_registry.FloorRegistry | None = None, devices: device_registry.DeviceRegistry | None = None, assistant: str | None = None, ) -> Iterable[State]: @@ -268,6 +302,15 @@ def async_match_states( if entities is None: entities = entity_registry.async_get(hass) + if devices is None: + devices = device_registry.async_get(hass) + + if areas is None: + areas = area_registry.async_get(hass) + + if floors is None: + floors = floor_registry.async_get(hass) + # Gather entities states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]] = [] for state in states: @@ -294,20 +337,35 @@ def async_match_states( if _is_device_class(state, entity, device_classes) ] + filter_areas: list[area_registry.AreaEntry] = [] + + if (floor is None) and (floor_name is not None): + # Look up floor by name + floor = _find_floor(floor_name, floors) + if floor is None: + _LOGGER.warning("Floor not found: %s", floor_name) + return + + if floor is not None: + filter_areas = [ + a for a in areas.async_list_areas() if a.floor_id == floor.floor_id + ] + if (area is None) and (area_name is not None): # Look up area by name - if areas is None: - areas = area_registry.async_get(hass) - area = _find_area(area_name, areas) - assert area is not None, f"No area named {area_name}" + if area is None: + _LOGGER.warning("Area not found: %s", area_name) + return if area is not None: - # Filter by states/entities by area - if devices is None: - devices = device_registry.async_get(hass) + filter_areas = [area] - states_and_entities = list(_filter_by_area(states_and_entities, area, devices)) + if filter_areas: + # Filter by states/entities by area + states_and_entities = list( + _filter_by_areas(states_and_entities, filter_areas, devices) + ) if assistant is not None: # Filter by exposure @@ -318,9 +376,6 @@ def async_match_states( ] if name is not None: - if devices is None: - devices = device_registry.async_get(hass) - # Filter by name name = name.casefold() @@ -389,7 +444,7 @@ class DynamicServiceIntentHandler(IntentHandler): """ slot_schema = { - vol.Any("name", "area"): cv.string, + vol.Any("name", "area", "floor"): cv.string, vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), } @@ -453,7 +508,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: # Don't match on name if targeting all entities entity_name = None - # Look up area first to fail early + # Look up area to fail early area_slot = slots.get("area", {}) area_id = area_slot.get("value") area_name = area_slot.get("text") @@ -464,6 +519,17 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: if area is None: raise IntentHandleError(f"No area named {area_name}") + # Look up floor to fail early + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + floor_name = floor_slot.get("text") + floor: floor_registry.FloorEntry | None = None + if floor_id is not None: + floors = floor_registry.async_get(hass) + floor = floors.async_get_floor(floor_id) + if floor is None: + raise IntentHandleError(f"No floor named {floor_name}") + # Optional domain/device class filters. # Convert to sets for speed. domains: set[str] | None = None @@ -480,6 +546,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: hass, name=entity_name, area=area, + floor=floor, domains=domains, device_classes=device_classes, assistant=intent_obj.assistant, @@ -491,6 +558,7 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: raise NoStatesMatchedError( name=entity_text or entity_name, area=area_name or area_id, + floor=floor_name or floor_id, domains=domains, device_classes=device_classes, ) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index ed6339f9996a84..6e8fa8dc3a39ff 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -31,9 +31,9 @@ def is_internal_request(hass: HomeAssistant) -> bool: get_url( hass, allow_external=False, allow_cloud=False, require_current_request=True ) - return True except NoURLAvailableError: return False + return True @bind_hass diff --git a/homeassistant/loader.py b/homeassistant/loader.py index f462ea16886f2f..722f3fd83c7059 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1550,6 +1550,20 @@ def __init__(self, hass: HomeAssistant) -> None: def __getattr__(self, helper_name: str) -> ModuleWrapper: """Fetch a helper.""" helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") + + # Local import to avoid circular dependencies + from .helpers.frame import report # pylint: disable=import-outside-toplevel + + report( + ( + f"accesses hass.helpers.{helper_name}." + " This is deprecated and will stop working in Home Assistant 2024.11, it" + f" should be updated to import functions used from {helper_name} directly" + ), + error_if_core=False, + log_custom_component_only=True, + ) + wrapped = ModuleWrapper(self._hass, helper) setattr(self, helper_name, wrapped) return wrapped diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2386845a2aded7..72cd71f889f9cd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ aiohttp==3.9.3 aiohttp_cors==0.7.0 astral==2.2 async-interrupt==1.1.1 -async-upnp-client==0.38.2 +async-upnp-client==0.38.3 atomicwrites-homeassistant==1.4.1 attrs==23.2.0 awesomeversion==24.2.0 @@ -30,8 +30,8 @@ habluetooth==2.4.2 hass-nabucasa==0.79.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240328.0 -home-assistant-intents==2024.3.27 +home-assistant-frontend==20240329.1 +home-assistant-intents==2024.3.29 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.3 diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 178ee6425e3221..32bb8f361d6e38 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -166,7 +166,6 @@ async def async_setup_component( setup_future.set_result(result) if setup_done_future := setup_done_futures.pop(domain, None): setup_done_future.set_result(result) - return result except BaseException as err: futures = [setup_future] if setup_done_future := setup_done_futures.pop(domain, None): @@ -185,6 +184,7 @@ async def async_setup_component( # if there are no concurrent setup attempts await future raise + return result async def _async_process_dependencies( diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 8c042242e0beb1..5ca19296b415b3 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -5,21 +5,15 @@ from asyncio import AbstractEventLoop, Future, Semaphore, Task, gather, get_running_loop from collections.abc import Awaitable, Callable, Coroutine import concurrent.futures -from contextlib import suppress -import functools import logging import threading -from typing import Any, ParamSpec, TypeVar, TypeVarTuple - -from homeassistant.exceptions import HomeAssistantError +from typing import Any, TypeVar, TypeVarTuple _LOGGER = logging.getLogger(__name__) _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" _T = TypeVar("_T") -_R = TypeVar("_R") -_P = ParamSpec("_P") _Ts = TypeVarTuple("_Ts") @@ -92,105 +86,6 @@ def run_callback() -> None: return future -def check_loop( - func: Callable[..., Any], strict: bool = True, advise_msg: str | None = None -) -> None: - """Warn if called inside the event loop. Raise if `strict` is True. - - The default advisory message is 'Use `await hass.async_add_executor_job()' - Set `advise_msg` to an alternate message if the solution differs. - """ - try: - get_running_loop() - in_loop = True - except RuntimeError: - in_loop = False - - if not in_loop: - return - - # Import only after we know we are running in the event loop - # so threads do not have to pay the late import cost. - # pylint: disable=import-outside-toplevel - from homeassistant.core import HomeAssistant, async_get_hass - from homeassistant.helpers.frame import ( - MissingIntegrationFrame, - get_current_frame, - get_integration_frame, - ) - from homeassistant.loader import async_suggest_report_issue - - found_frame = None - - if func.__name__ == "sleep": - # - # Avoid extracting the stack unless we need to since it - # will have to access the linecache which can do blocking - # I/O and we are trying to avoid blocking calls. - # - # frame[1] is us - # frame[2] is protected_loop_func - # frame[3] is the offender - with suppress(ValueError): - offender_frame = get_current_frame(3) - if offender_frame.f_code.co_filename.endswith("pydevd.py"): - return - - try: - integration_frame = get_integration_frame() - except MissingIntegrationFrame: - # Did not source from integration? Hard error. - if found_frame is None: - raise RuntimeError( # noqa: TRY200 - f"Detected blocking call to {func.__name__} inside the event loop. " - f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " - "This is causing stability issues. Please create a bug report at " - f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() - report_issue = async_suggest_report_issue( - hass, - integration_domain=integration_frame.integration, - module=integration_frame.module, - ) - - _LOGGER.warning( - ( - "Detected blocking call to %s inside the event loop by %sintegration '%s' " - "at %s, line %s: %s, please %s" - ), - func.__name__, - "custom " if integration_frame.custom_integration else "", - integration_frame.integration, - integration_frame.relative_filename, - integration_frame.line_number, - integration_frame.line, - report_issue, - ) - - if strict: - raise RuntimeError( - "Blocking calls must be done in the executor or a separate thread;" - f" {advise_msg or 'Use `await hass.async_add_executor_job()`'}; at" - f" {integration_frame.relative_filename}, line {integration_frame.line_number}:" - f" {integration_frame.line}" - ) - - -def protect_loop(func: Callable[_P, _R], strict: bool = True) -> Callable[_P, _R]: - """Protect function from running in event loop.""" - - @functools.wraps(func) - def protected_loop_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: - check_loop(func, strict=strict) - return func(*args, **kwargs) - - return protected_loop_func - - async def gather_with_limited_concurrency( limit: int, *tasks: Any, return_exceptions: bool = False ) -> Any: diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py new file mode 100644 index 00000000000000..f8fe5c701f3e0d --- /dev/null +++ b/homeassistant/util/loop.py @@ -0,0 +1,146 @@ +"""asyncio loop utilities.""" + +from __future__ import annotations + +from asyncio import get_running_loop +from collections.abc import Callable +from contextlib import suppress +import functools +import linecache +import logging +from typing import Any, ParamSpec, TypeVar + +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import ( + MissingIntegrationFrame, + get_current_frame, + get_integration_frame, +) +from homeassistant.loader import async_suggest_report_issue + +_LOGGER = logging.getLogger(__name__) + + +_R = TypeVar("_R") +_P = ParamSpec("_P") + + +def _get_line_from_cache(filename: str, lineno: int) -> str: + """Get line from cache or read from file.""" + return (linecache.getline(filename, lineno) or "?").strip() + + +def check_loop( + func: Callable[..., Any], + check_allowed: Callable[[dict[str, Any]], bool] | None = None, + strict: bool = True, + strict_core: bool = True, + advise_msg: str | None = None, + **mapped_args: Any, +) -> None: + """Warn if called inside the event loop. Raise if `strict` is True. + + The default advisory message is 'Use `await hass.async_add_executor_job()' + Set `advise_msg` to an alternate message if the solution differs. + """ + try: + get_running_loop() + in_loop = True + except RuntimeError: + in_loop = False + + if not in_loop: + return + + if check_allowed is not None and check_allowed(mapped_args): + return + + found_frame = None + offender_frame = get_current_frame(2) + offender_filename = offender_frame.f_code.co_filename + offender_lineno = offender_frame.f_lineno + offender_line = _get_line_from_cache(offender_filename, offender_lineno) + + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + # Did not source from integration? Hard error. + if not strict_core: + _LOGGER.warning( + "Detected blocking call to %s with args %s in %s, " + "line %s: %s inside the event loop", + func.__name__, + mapped_args.get("args"), + offender_filename, + offender_lineno, + offender_line, + ) + return + + if found_frame is None: + raise RuntimeError( # noqa: TRY200 + f"Detected blocking call to {func.__name__} inside the event loop " + f"in {offender_filename}, line {offender_lineno}: {offender_line}. " + f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " + "This is causing stability issues. Please create a bug report at " + f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) + + _LOGGER.warning( + ( + "Detected blocking call to %s inside the event loop by %sintegration '%s' " + "at %s, line %s: %s (offender: %s, line %s: %s), please %s" + ), + func.__name__, + "custom " if integration_frame.custom_integration else "", + integration_frame.integration, + integration_frame.relative_filename, + integration_frame.line_number, + integration_frame.line, + offender_filename, + offender_lineno, + offender_line, + report_issue, + ) + + if strict: + raise RuntimeError( + "Blocking calls must be done in the executor or a separate thread;" + f" {advise_msg or 'Use `await hass.async_add_executor_job()`'}; at" + f" {integration_frame.relative_filename}, line {integration_frame.line_number}:" + f" {integration_frame.line} " + f"(offender: {offender_filename}, line {offender_lineno}: {offender_line})" + ) + + +def protect_loop( + func: Callable[_P, _R], + strict: bool = True, + strict_core: bool = True, + check_allowed: Callable[[dict[str, Any]], bool] | None = None, +) -> Callable[_P, _R]: + """Protect function from running in event loop.""" + + @functools.wraps(func) + def protected_loop_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: + check_loop( + func, + strict=strict, + strict_core=strict_core, + check_allowed=check_allowed, + args=args, + kwargs=kwargs, + ) + return func(*args, **kwargs) + + return protected_loop_func diff --git a/mypy.ini b/mypy.ini index afe124a053a9c2..ed92f11e40a6b9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1421,6 +1421,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.energenie_power_sockets.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.energy.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 891ea511ba5ab0..1e3e4c8637246f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -304,6 +304,10 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 + "no-else-break", # RET508 + "no-else-continue", # RET507 + "no-else-raise", # RET506 + "no-else-return", # RET505 # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy @@ -633,6 +637,7 @@ select = [ "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style + "RET", # flake8-return "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task @@ -713,9 +718,12 @@ ignore = [ # temporarily disabled "PT019", + "RET504", + "RET503", + "RET502", + "RET501", "TRY002", - "TRY301", - "TRY300" + "TRY301" ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/requirements_all.txt b/requirements_all.txt index 0f75eb68e42e49..37e2b8a24e4298 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -224,7 +224,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 # homeassistant.components.dnsip -aiodns==3.1.1 +aiodns==3.2.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -487,7 +487,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.38.2 +async-upnp-client==0.38.3 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -514,7 +514,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==59 +axis==60 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -1081,10 +1081,10 @@ hole==0.8.0 holidays==0.45 # homeassistant.components.frontend -home-assistant-frontend==20240328.0 +home-assistant-frontend==20240329.1 # homeassistant.components.conversation -home-assistant-intents==2024.3.27 +home-assistant-intents==2024.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1802,6 +1802,9 @@ pyedimax==0.2.1 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.energenie_power_sockets +pyegps==0.2.5 + # homeassistant.components.enphase_envoy pyenphase==1.20.1 @@ -2042,7 +2045,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.8 +pyoverkiz==1.13.9 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -2300,7 +2303,7 @@ python-roborock==0.40.0 python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16.1 +python-songpal==0.16.2 # homeassistant.components.tado python-tado==0.17.4 @@ -2451,7 +2454,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.8.7 +ring-doorbell[listen]==0.8.8 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a920fe2c618c9..f17bfbf4851ef1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -203,7 +203,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 # homeassistant.components.dnsip -aiodns==3.1.1 +aiodns==3.2.0 # homeassistant.components.eafm aioeafm==0.1.2 @@ -442,7 +442,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.38.2 +async-upnp-client==0.38.3 # homeassistant.components.sleepiq asyncsleepiq==1.5.2 @@ -454,7 +454,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==59 +axis==60 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -880,10 +880,10 @@ hole==0.8.0 holidays==0.45 # homeassistant.components.frontend -home-assistant-frontend==20240328.0 +home-assistant-frontend==20240329.1 # homeassistant.components.conversation -home-assistant-intents==2024.3.27 +home-assistant-intents==2024.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1401,6 +1401,9 @@ pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.energenie_power_sockets +pyegps==0.2.5 + # homeassistant.components.enphase_envoy pyenphase==1.20.1 @@ -1590,7 +1593,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.8 +pyoverkiz==1.13.9 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1776,7 +1779,7 @@ python-roborock==0.40.0 python-smarttub==0.0.36 # homeassistant.components.songpal -python-songpal==0.16.1 +python-songpal==0.16.2 # homeassistant.components.tado python-tado==0.17.4 @@ -1894,7 +1897,7 @@ reolink-aio==0.8.9 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.8.7 +ring-doorbell[listen]==0.8.8 # homeassistant.components.roku rokuecp==0.19.2 diff --git a/script/translations/develop.py b/script/translations/develop.py index 14e3c320c3eb68..00465e1bc24530 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -43,7 +43,7 @@ def flatten_translations(translations): if isinstance(v, dict): stack.append(iter(v.items())) break - elif isinstance(v, str): + if isinstance(v, str): common_key = "::".join(key_stack) flattened_translations[common_key] = v key_stack.pop() diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index d60b42eddf277a..ba213e0c2e742a 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -12,7 +12,8 @@ from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -232,9 +233,8 @@ def create_entry(hass): return entry -def create_device(hass, entry): +def create_device(entry: ConfigEntry, device_registry: DeviceRegistry): """Create a device for the given entry.""" - device_registry = hass.helpers.device_registry.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index eee4de263d6933..9949528ccc7be3 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.airthings_ble import ( CO2_V1, @@ -25,18 +26,20 @@ _LOGGER = logging.getLogger(__name__) -async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): +async def test_migration_from_v1_to_v3_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +): """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" entry = create_entry(hass) - device = create_device(hass, entry) + device = create_device(entry, device_registry) assert entry is not None assert device is not None new_unique_id = f"{WAVE_DEVICE_INFO.address}_temperature" - entity_registry = hass.helpers.entity_registry.async_get(hass) - sensor = entity_registry.async_get_or_create( domain=DOMAIN, platform=Platform.SENSOR, @@ -64,18 +67,18 @@ async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id -async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): +async def test_migration_from_v2_to_v3_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +): """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" entry = create_entry(hass) - device = create_device(hass, entry) + device = create_device(entry, device_registry) assert entry is not None assert device is not None - entity_registry = hass.helpers.entity_registry.async_get(hass) - - await hass.async_block_till_done() - sensor = entity_registry.async_get_or_create( domain=DOMAIN, platform=Platform.SENSOR, @@ -105,18 +108,18 @@ async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id -async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): +async def test_migration_from_v1_and_v2_to_v3_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +): """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" entry = create_entry(hass) - device = create_device(hass, entry) + device = create_device(entry, device_registry) assert entry is not None assert device is not None - entity_registry = hass.helpers.entity_registry.async_get(hass) - - await hass.async_block_till_done() - v2 = entity_registry.async_get_or_create( domain=DOMAIN, platform=Platform.SENSOR, @@ -155,18 +158,18 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): assert entity_registry.async_get(v2.entity_id).unique_id == CO2_V2.unique_id -async def test_migration_with_all_unique_ids(hass: HomeAssistant): +async def test_migration_with_all_unique_ids( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +): """Test if migration works when we have all unique ids.""" entry = create_entry(hass) - device = create_device(hass, entry) + device = create_device(entry, device_registry) assert entry is not None assert device is not None - entity_registry = hass.helpers.entity_registry.async_get(hass) - - await hass.async_block_till_done() - v1 = entity_registry.async_get_or_create( domain=DOMAIN, platform=Platform.SENSOR, diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index 49ec0ce8d5225c..16ca0812d7dcd7 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -162,7 +162,7 @@ async def test_form_already_configured( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["reason"] == "single_instance_allowed" @pytest.mark.parametrize( diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 178cf165f67488..4bc5a5d30864fc 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -201,7 +201,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) power = hass.states.get("sensor.mydevicename_total_energy") assert power.state == "unknown" # sun rose again @@ -218,7 +218,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) power = hass.states.get("sensor.mydevicename_power_output") assert power is not None assert power.state == "45.7" @@ -237,7 +237,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) power = hass.states.get("sensor.mydevicename_power_output") assert power.state == "unknown" # should this be 'available'? @@ -277,7 +277,7 @@ async def test_sensor_unknown_error( ): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( "Exception: AuroraError('another error') occurred, 2 retries remaining" in caplog.text diff --git a/tests/components/axis/const.py b/tests/components/axis/const.py index 7b881ea55e5b1c..16b9d17f99e7c8 100644 --- a/tests/components/axis/const.py +++ b/tests/components/axis/const.py @@ -74,6 +74,7 @@ "status": {"state": "active", "connectionStatus": "Connected"}, "config": { "server": {"protocol": "tcp", "host": "192.168.0.90", "port": 1883}, + "deviceTopicPrefix": f"axis/{MAC}", }, }, } diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 3291f88d90afd9..1ae6db05427f75 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -91,9 +91,9 @@ async def test_device_support_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry ) -> None: """Successful setup.""" - mqtt_mock.async_subscribe.assert_called_with(f"{MAC}/#", mock.ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_called_with(f"axis/{MAC}/#", mock.ANY, 0, "utf-8") - topic = f"{MAC}/event/tns:onvif/Device/tns:axis/Sensor/PIR/$source/sensor/0" + topic = f"axis/{MAC}/event/tns:onvif/Device/tns:axis/Sensor/PIR/$source/sensor/0" message = ( b'{"timestamp": 1590258472044, "topic": "onvif:Device/axis:Sensor/PIR",' b' "message": {"source": {"sensor": "0"}, "key": {}, "data": {"state": "1"}}}' diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 62c21fc95ee3aa..a7b9311e88be6e 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -278,7 +278,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: result["flow_id"], {"known_hosts": "192.168.0.1, 192.168.0.2"} ) assert result["type"] == "create_entry" - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) config_entry = hass.config_entries.async_entries("cast")[0] assert castbrowser_mock.return_value.start_discovery.call_count == 1 @@ -291,7 +291,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: user_input={"known_hosts": "192.168.0.11, 192.168.0.12"}, ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) castbrowser_mock.return_value.start_discovery.assert_not_called() castbrowser_mock.assert_not_called() diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 9ef31457d5c30c..d75aebe4dedee7 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -137,8 +137,8 @@ async def async_setup_cast_internal_discovery(hass, config=None): return_value=browser, ) as cast_browser: add_entities = await async_setup_cast(hass, config) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) assert browser.start_discovery.call_count == 1 @@ -209,8 +209,8 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf entry = MockConfigEntry(data=data, domain="cast") entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) discovery_callback = cast_browser.call_args[0][0].add_cast @@ -453,11 +453,13 @@ async def test_stop_discovery_called_on_stop( """Test pychromecast.stop_discovery called on shutdown.""" # start_discovery should be called with empty config await async_setup_cast(hass, {}) + await hass.async_block_till_done() assert castbrowser_mock.return_value.start_discovery.call_count == 1 # stop discovery should be called on shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() + await hass.async_block_till_done() assert castbrowser_mock.return_value.stop_discovery.call_count == 1 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 09e74142ad3672..958c7fe3c86e03 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockToggleEntity +from tests.components.conversation import MockAgent if TYPE_CHECKING: from tests.components.device_tracker.common import MockScanner @@ -104,6 +105,16 @@ def tts_mutagen_mock_fixture(): yield from tts_mutagen_mock_fixture_helper() +@pytest.fixture(name="mock_conversation_agent") +def mock_conversation_agent_fixture(hass: HomeAssistant) -> MockAgent: + """Mock a conversation agent.""" + from tests.components.conversation.common import ( + mock_conversation_agent_fixture_helper, + ) + + return mock_conversation_agent_fixture_helper(hass) + + @pytest.fixture(scope="session", autouse=True) def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: """Prevent ffmpeg from creating a subprocess.""" diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 7209148e21feec..fb9bcab7498ea3 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -5,11 +5,16 @@ from typing import Literal from homeassistant.components import conversation +from homeassistant.components.conversation.models import ( + ConversationInput, + ConversationResult, +) from homeassistant.components.homeassistant.exposed_entities import ( DATA_EXPOSED_ENTITIES, ExposedEntities, async_expose_entity, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -30,24 +35,22 @@ def supported_languages(self) -> list[str]: """Return a list of supported languages.""" return self._supported_languages - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: + async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process some text.""" self.calls.append(user_input) response = intent.IntentResponse(language=user_input.language) response.async_set_speech(self.response) - return conversation.ConversationResult( + return ConversationResult( response=response, conversation_id=user_input.conversation_id ) -def expose_new(hass, expose_new): +def expose_new(hass: HomeAssistant, expose_new: bool): """Enable exposing new entities to the default agent.""" exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES] exposed_entities.async_set_expose_new_entities(conversation.DOMAIN, expose_new) -def expose_entity(hass, entity_id, should_expose): +def expose_entity(hass: HomeAssistant, entity_id: str, should_expose: bool): """Expose an entity to the default agent.""" async_expose_entity(hass, conversation.DOMAIN, entity_id, should_expose) diff --git a/tests/components/conversation/common.py b/tests/components/conversation/common.py new file mode 100644 index 00000000000000..2fa152b1eb2d38 --- /dev/null +++ b/tests/components/conversation/common.py @@ -0,0 +1,17 @@ +"""Provide common tests tools for conversation.""" + +from homeassistant.components import conversation +from homeassistant.core import HomeAssistant + +from . import MockAgent + +from tests.common import MockConfigEntry + + +def mock_conversation_agent_fixture_helper(hass: HomeAssistant) -> MockAgent: + """Mock agent.""" + entry = MockConfigEntry(entry_id="mock-entry") + entry.add_to_hass(hass) + agent = MockAgent(entry.entry_id, ["smurfish"]) + conversation.async_set_agent(hass, entry, agent) + return agent diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index cf6b4567228300..d6c2d9e2e5ed2c 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -13,16 +13,6 @@ from tests.common import MockConfigEntry -@pytest.fixture -def mock_agent(hass): - """Mock agent.""" - entry = MockConfigEntry(entry_id="mock-entry") - entry.add_to_hass(hass) - agent = MockAgent(entry.entry_id, ["smurfish"]) - conversation.async_set_agent(hass, entry, agent) - return agent - - @pytest.fixture def mock_agent_support_all(hass): """Mock agent that supports all languages.""" diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index aefb37f427e2a5..c600c71711e48d 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import conversation +from homeassistant.components.conversation import agent_manager, default_agent from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, ) @@ -17,6 +18,7 @@ device_registry as dr, entity, entity_registry as er, + floor_registry as fr, intent, ) from homeassistant.setup import async_setup_component @@ -150,7 +152,7 @@ async def test_conversation_agent( init_components, ) -> None: """Test DefaultAgent.""" - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await agent_manager.get_agent_manager(hass).async_get_agent( conversation.HOME_ASSISTANT_AGENT ) with patch( @@ -252,10 +254,10 @@ async def test_trigger_sentences(hass: HomeAssistant, init_components) -> None: trigger_sentences = ["It's party time", "It is time to party"] trigger_response = "Cowabunga!" - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await agent_manager.get_agent_manager(hass).async_get_agent( conversation.HOME_ASSISTANT_AGENT ) - assert isinstance(agent, conversation.DefaultAgent) + assert isinstance(agent, default_agent.DefaultAgent) callback = AsyncMock(return_value=trigger_response) unregister = agent.register_trigger(trigger_sentences, callback) @@ -480,6 +482,20 @@ async def test_error_no_area(hass: HomeAssistant, init_components) -> None: ) +async def test_error_no_floor(hass: HomeAssistant, init_components) -> None: + """Test error message when floor is missing.""" + result = await conversation.async_converse( + hass, "turn on all the lights on missing floor", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any floor called missing" + ) + + async def test_error_no_device_in_area( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: @@ -549,6 +565,48 @@ async def test_error_no_domain_in_area( ) +async def test_error_no_domain_in_floor( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test error message when no devices/entities for a domain exist on a floor.""" + floor_ground = floor_registry.async_create("ground") + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update( + area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id + ) + result = await conversation.async_converse( + hass, "turn on all lights on the ground floor", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any light on the ground floor" + ) + + # Add a new floor/area to trigger registry event handlers + floor_upstairs = floor_registry.async_create("upstairs") + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update( + area_bedroom.id, name="bedroom", floor_id=floor_upstairs.floor_id + ) + + result = await conversation.async_converse( + hass, "turn on all lights upstairs", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am not aware of any light on the upstairs floor" + ) + + async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None: """Test error message when no entities of a device class exist.""" @@ -736,7 +794,7 @@ async def test_no_states_matched_default_error( with patch( "homeassistant.components.conversation.default_agent.intent.async_handle", - side_effect=intent.NoStatesMatchedError(None, None, None, None), + side_effect=intent.NoStatesMatchedError(), ): result = await conversation.async_converse( hass, "turn on lights in the kitchen", None, Context(), None @@ -759,11 +817,16 @@ async def test_empty_aliases( area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test that empty aliases are not added to slot lists.""" + floor_1 = floor_registry.async_create("first floor", aliases={" "}) + area_kitchen = area_registry.async_get_or_create("kitchen_id") area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") - area_kitchen = area_registry.async_update(area_kitchen.id, aliases={" "}) + area_kitchen = area_registry.async_update( + area_kitchen.id, aliases={" "}, floor_id=floor_1 + ) entry = MockConfigEntry() entry.add_to_hass(hass) @@ -788,7 +851,7 @@ async def test_empty_aliases( ) with patch( - "homeassistant.components.conversation.DefaultAgent._recognize", + "homeassistant.components.conversation.default_agent.DefaultAgent._recognize", return_value=None, ) as mock_recognize_all: await conversation.async_converse( @@ -799,7 +862,7 @@ async def test_empty_aliases( slot_lists = mock_recognize_all.call_args[0][2] # Slot lists should only contain non-empty text - assert slot_lists.keys() == {"area", "name"} + assert slot_lists.keys() == {"area", "name", "floor"} areas = slot_lists["area"] assert len(areas.values) == 1 assert areas.values[0].value_out == area_kitchen.id @@ -810,6 +873,11 @@ async def test_empty_aliases( assert names.values[0].value_out == kitchen_light.name assert names.values[0].text_in.text == kitchen_light.name + floors = slot_lists["floor"] + assert len(floors.values) == 1 + assert floors.values[0].value_out == floor_1.floor_id + assert floors.values[0].text_in.text == floor_1.name + async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None: """Test that sentences for all domains are always loaded.""" diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index c57d93d8ceff5a..9636ac07f63f38 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -2,14 +2,26 @@ import pytest -from homeassistant.components import conversation, cover, media_player, vacuum, valve +from homeassistant.components import ( + conversation, + cover, + light, + media_player, + vacuum, + valve, +) from homeassistant.components.cover import intent as cover_intent from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.media_player import intent as media_player_intent from homeassistant.components.vacuum import intent as vaccum_intent from homeassistant.const import STATE_CLOSED from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -244,3 +256,92 @@ async def test_media_player_intents( "entity_id": entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.75, } + + +async def test_turn_floor_lights_on_off( + hass: HomeAssistant, + init_components, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test that we can turn lights on/off for an entire floor.""" + floor_ground = floor_registry.async_create("ground", aliases={"downstairs"}) + floor_upstairs = floor_registry.async_create("upstairs") + + # Kitchen and living room are on the ground floor + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update( + area_kitchen.id, name="kitchen", floor_id=floor_ground.floor_id + ) + + area_living_room = area_registry.async_get_or_create("living_room_id") + area_living_room = area_registry.async_update( + area_living_room.id, name="living_room", floor_id=floor_ground.floor_id + ) + + # Bedroom is upstairs + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update( + area_bedroom.id, name="bedroom", floor_id=floor_upstairs.floor_id + ) + + # One light per area + kitchen_light = entity_registry.async_get_or_create( + "light", "demo", "kitchen_light" + ) + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, area_id=area_kitchen.id + ) + hass.states.async_set(kitchen_light.entity_id, "off") + + living_room_light = entity_registry.async_get_or_create( + "light", "demo", "living_room_light" + ) + living_room_light = entity_registry.async_update_entity( + living_room_light.entity_id, area_id=area_living_room.id + ) + hass.states.async_set(living_room_light.entity_id, "off") + + bedroom_light = entity_registry.async_get_or_create( + "light", "demo", "bedroom_light" + ) + bedroom_light = entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=area_bedroom.id + ) + hass.states.async_set(bedroom_light.entity_id, "off") + + # Target by floor + on_calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + result = await conversation.async_converse( + hass, "turn on all lights downstairs", None, Context(), None + ) + + assert len(on_calls) == 2 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert {s.entity_id for s in result.response.matched_states} == { + kitchen_light.entity_id, + living_room_light.entity_id, + } + + on_calls.clear() + result = await conversation.async_converse( + hass, "upstairs lights on", None, Context(), None + ) + + assert len(on_calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert {s.entity_id for s in result.response.matched_states} == { + bedroom_light.entity_id + } + + off_calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_OFF) + result = await conversation.async_converse( + hass, "turn upstairs lights off", None, Context(), None + ) + + assert len(off_calls) == 1 + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert {s.entity_id for s in result.response.matched_states} == { + bedroom_light.entity_id + } diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 1ef8c8b30d75f6..62f67548ece889 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -9,6 +9,8 @@ import voluptuous as vol from homeassistant.components import conversation +from homeassistant.components.conversation import agent_manager, default_agent +from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME @@ -94,7 +96,7 @@ async def test_http_processing_intent_target_ha_agent( init_components, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, - mock_agent, + mock_conversation_agent, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -658,7 +660,7 @@ async def test_custom_agent( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, - mock_agent, + mock_conversation_agent, snapshot: SnapshotAssertion, ) -> None: """Test a custom conversation agent.""" @@ -672,7 +674,7 @@ async def test_custom_agent( "text": "Test Text", "conversation_id": "test-conv-id", "language": "test-language", - "agent_id": mock_agent.agent_id, + "agent_id": mock_conversation_agent.agent_id, } resp = await client.post("/api/conversation/process", json=data) @@ -683,14 +685,14 @@ async def test_custom_agent( assert data["response"]["speech"]["plain"]["speech"] == "Test response" assert data["conversation_id"] == "test-conv-id" - assert len(mock_agent.calls) == 1 - assert mock_agent.calls[0].text == "Test Text" - assert mock_agent.calls[0].context.user_id == hass_admin_user.id - assert mock_agent.calls[0].conversation_id == "test-conv-id" - assert mock_agent.calls[0].language == "test-language" + assert len(mock_conversation_agent.calls) == 1 + assert mock_conversation_agent.calls[0].text == "Test Text" + assert mock_conversation_agent.calls[0].context.user_id == hass_admin_user.id + assert mock_conversation_agent.calls[0].conversation_id == "test-conv-id" + assert mock_conversation_agent.calls[0].language == "test-language" conversation.async_unset_agent( - hass, hass.config_entries.async_get_entry(mock_agent.agent_id) + hass, hass.config_entries.async_get_entry(mock_conversation_agent.agent_id) ) @@ -750,8 +752,8 @@ async def test_ws_prepare( """Test the Websocket prepare conversation API.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) + agent = await agent_manager.get_agent_manager(hass).async_get_agent() + assert isinstance(agent, default_agent.DefaultAgent) # No intents should be loaded yet assert not agent._lang_intents.get(hass.config.language) @@ -852,8 +854,8 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) # Load intents - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) + agent = await agent_manager.get_agent_manager(hass).async_get_agent() + assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare(language) # Confirm intents are loaded @@ -880,8 +882,8 @@ async def test_prepare_fail(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) # Load intents - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) + agent = await agent_manager.get_agent_manager(hass).async_get_agent() + assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare("not-a-language") # Confirm no intents were loaded @@ -917,11 +919,11 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non hass.states.async_set("cover.front_door", "closed") calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) + agent = await agent_manager.get_agent_manager(hass).async_get_agent() + assert isinstance(agent, default_agent.DefaultAgent) result = await agent.async_process( - conversation.ConversationInput( + ConversationInput( text="open the front door", context=Context(), conversation_id=None, @@ -1072,7 +1074,7 @@ async def test_agent_id_validator_invalid_agent(hass: HomeAssistant) -> None: async def test_get_agent_list( hass: HomeAssistant, init_components, - mock_agent, + mock_conversation_agent, mock_agent_support_all, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, @@ -1128,14 +1130,20 @@ async def test_get_agent_list( async def test_get_agent_info( - hass: HomeAssistant, init_components, mock_agent, snapshot: SnapshotAssertion + hass: HomeAssistant, + init_components, + mock_conversation_agent, + snapshot: SnapshotAssertion, ) -> None: """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot - assert conversation.async_get_agent_info(hass, mock_agent.agent_id) == snapshot + assert ( + conversation.async_get_agent_info(hass, mock_conversation_agent.agent_id) + == snapshot + ) assert conversation.async_get_agent_info(hass, "not exist") is None # Test the name when config entry title is empty diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 221789b49e0b73..33ad8efdd2eeab 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -5,7 +5,8 @@ import pytest import voluptuous as vol -from homeassistant.components import conversation +from homeassistant.components.conversation import agent_manager, default_agent +from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import trigger from homeassistant.setup import async_setup_component @@ -514,11 +515,11 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: }, ) - agent = await conversation._get_agent_manager(hass).async_get_agent() - assert isinstance(agent, conversation.DefaultAgent) + agent = await agent_manager.get_agent_manager(hass).async_get_agent() + assert isinstance(agent, default_agent.DefaultAgent) result = await agent.async_process( - conversation.ConversationInput( + ConversationInput( text="test sentence", context=Context(), conversation_id=None, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 95def2f66cf0a4..429128c48bbdaa 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -6,13 +6,16 @@ from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry async def test_migrate_gas_to_mbus( - hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + dsmr_connection_fixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -42,7 +45,6 @@ async def test_migrate_gas_to_mbus( old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" - device_registry = hass.helpers.device_registry.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=mock_entry.entry_id, identifiers={(DOMAIN, mock_entry.entry_id)}, @@ -108,7 +110,10 @@ async def test_migrate_gas_to_mbus( async def test_migrate_gas_to_mbus_exists( - hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + dsmr_connection_fixture, ) -> None: """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -138,7 +143,6 @@ async def test_migrate_gas_to_mbus_exists( old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" - device_registry = hass.helpers.device_registry.async_get(hass) device = device_registry.async_get_or_create( config_entry_id=mock_entry.entry_id, identifiers={(DOMAIN, mock_entry.entry_id)}, diff --git a/tests/components/energenie_power_sockets/__init__.py b/tests/components/energenie_power_sockets/__init__.py new file mode 100644 index 00000000000000..8397567ef82fea --- /dev/null +++ b/tests/components/energenie_power_sockets/__init__.py @@ -0,0 +1 @@ +"""Tests for Energenie-Power-Sockets (EGPS) integration.""" diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py new file mode 100644 index 00000000000000..f119c0008f7c5e --- /dev/null +++ b/tests/components/energenie_power_sockets/conftest.py @@ -0,0 +1,83 @@ +"""Configure tests for Energenie-Power-Sockets.""" + +from collections.abc import Generator +from typing import Final +from unittest.mock import MagicMock, patch + +from pyegps.fakes.powerstrip import FakePowerStrip +import pytest + +from homeassistant.components.energenie_power_sockets.const import ( + CONF_DEVICE_API_ID, + DOMAIN, +) +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + +DEMO_CONFIG_DATA: Final = { + CONF_NAME: "Unit Test", + CONF_DEVICE_API_ID: "DYPS:00:11:22", +} + + +@pytest.fixture +def demo_config_data() -> dict: + """Return valid user input.""" + return {CONF_DEVICE_API_ID: DEMO_CONFIG_DATA[CONF_DEVICE_API_ID]} + + +@pytest.fixture +def valid_config_entry() -> MockConfigEntry: + """Return a valid egps config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=DEMO_CONFIG_DATA, + unique_id=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], + ) + + +@pytest.fixture(name="pyegps_device_mock") +def get_pyegps_device_mock() -> MagicMock: + """Fixture for a mocked FakePowerStrip.""" + + fkObj = FakePowerStrip( + devId=DEMO_CONFIG_DATA[CONF_DEVICE_API_ID], number_of_sockets=4 + ) + fkObj.release = lambda: True + fkObj._status = [0, 1, 0, 1] + + usb_device_mock = MagicMock(wraps=fkObj) + usb_device_mock.get_device_type.return_value = "PowerStrip" + usb_device_mock.numberOfSockets = 4 + usb_device_mock.device_id = DEMO_CONFIG_DATA[CONF_DEVICE_API_ID] + usb_device_mock.manufacturer = "Energenie" + usb_device_mock.name = "MockedUSBDevice" + + return usb_device_mock + + +@pytest.fixture(name="mock_get_device") +def patch_get_device(pyegps_device_mock: MagicMock) -> Generator[MagicMock, None, None]: + """Fixture to patch the `get_device` api method.""" + with ( + patch("homeassistant.components.energenie_power_sockets.get_device") as m1, + patch( + "homeassistant.components.energenie_power_sockets.config_flow.get_device", + new=m1, + ) as mock, + ): + mock.return_value = pyegps_device_mock + yield mock + + +@pytest.fixture(name="mock_search_for_devices") +def patch_search_devices( + pyegps_device_mock: MagicMock, +) -> Generator[MagicMock, None, None]: + """Fixture to patch the `search_for_devices` api method.""" + with patch( + "homeassistant.components.energenie_power_sockets.config_flow.search_for_devices", + return_value=[pyegps_device_mock], + ) as mock: + yield mock diff --git a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..d462d6ca6d4e9e --- /dev/null +++ b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_switch_setup[mockedusbdevice_socket_0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'MockedUSBDevice Socket 0', + }), + 'context': , + 'entity_id': 'switch.mockedusbdevice_socket_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_0].1 + 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.mockedusbdevice_socket_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 0', + 'platform': 'energenie_power_sockets', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'DYPS:00:11:22_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'MockedUSBDevice Socket 1', + }), + 'context': , + 'entity_id': 'switch.mockedusbdevice_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_1].1 + 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.mockedusbdevice_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'energenie_power_sockets', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'DYPS:00:11:22_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'MockedUSBDevice Socket 2', + }), + 'context': , + 'entity_id': 'switch.mockedusbdevice_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_2].1 + 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.mockedusbdevice_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'energenie_power_sockets', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'DYPS:00:11:22_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_3] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'MockedUSBDevice Socket 3', + }), + 'context': , + 'entity_id': 'switch.mockedusbdevice_socket_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[mockedusbdevice_socket_3].1 + 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.mockedusbdevice_socket_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 3', + 'platform': 'energenie_power_sockets', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket', + 'unique_id': 'DYPS:00:11:22_3', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/energenie_power_sockets/test_config_flow.py b/tests/components/energenie_power_sockets/test_config_flow.py new file mode 100644 index 00000000000000..ef433d0ef09d05 --- /dev/null +++ b/tests/components/energenie_power_sockets/test_config_flow.py @@ -0,0 +1,140 @@ +"""Tests for Energenie-Power-Sockets config flow.""" + +from unittest.mock import MagicMock + +from pyegps.exceptions import UsbError + +from homeassistant.components.energenie_power_sockets.const import ( + CONF_DEVICE_API_ID, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow( + hass: HomeAssistant, + demo_config_data: dict, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test configuration flow initialized by the user.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + # check with valid data + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], user_input=demo_config_data + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_already_exists( + hass: HomeAssistant, + valid_config_entry: MockConfigEntry, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test the flow when device has been already configured.""" + valid_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_DEVICE_API_ID: valid_config_entry.data[CONF_DEVICE_API_ID]}, + ) + + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_no_new_device( + hass: HomeAssistant, + valid_config_entry: MockConfigEntry, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test the flow when the found device has been already included.""" + valid_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=None, + ) + + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_device" + + +async def test_user_flow_no_device_found( + hass: HomeAssistant, + demo_config_data: dict, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test configuration flow when no device is found.""" + + mock_search_for_devices.return_value = [] + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result1["type"] == FlowResultType.ABORT + assert result1["reason"] == "no_device" + + +async def test_user_flow_device_not_found( + hass: HomeAssistant, + demo_config_data: dict, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test configuration flow when the given device_id does not match any found devices.""" + + mock_get_device.return_value = None + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + # check with valid data + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], user_input=demo_config_data + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "device_not_found" + + +async def test_user_flow_no_usb_access( + hass: HomeAssistant, + mock_get_device: MagicMock, + mock_search_for_devices: MagicMock, +) -> None: + """Test configuration flow when USB devices can't be accessed.""" + + mock_get_device.return_value = None + mock_search_for_devices.side_effect = UsbError + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result1["type"] == FlowResultType.ABORT + assert result1["reason"] == "usb_error" diff --git a/tests/components/energenie_power_sockets/test_init.py b/tests/components/energenie_power_sockets/test_init.py new file mode 100644 index 00000000000000..a60949c34cc6ae --- /dev/null +++ b/tests/components/energenie_power_sockets/test_init.py @@ -0,0 +1,64 @@ +"""Tests for setting up Energenie-Power-Sockets integration.""" + +from unittest.mock import MagicMock + +from pyegps.exceptions import UsbError + +from homeassistant.components.energenie_power_sockets.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + valid_config_entry: MockConfigEntry, + mock_get_device: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + entry = valid_config_entry + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + assert entry.entry_id in hass.data[DOMAIN] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert DOMAIN not in hass.data + + +async def test_device_not_found_on_load_entry( + hass: HomeAssistant, + valid_config_entry: MockConfigEntry, + mock_get_device: MagicMock, +) -> None: + """Test device not available on config entry setup.""" + + mock_get_device.return_value = None + + valid_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(valid_config_entry.entry_id) + await hass.async_block_till_done() + + assert valid_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_usb_error( + hass: HomeAssistant, valid_config_entry: MockConfigEntry, mock_get_device: MagicMock +) -> None: + """Test no USB access on config entry setup.""" + + mock_get_device.side_effect = UsbError + + valid_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(valid_config_entry.entry_id) + await hass.async_block_till_done() + + assert valid_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/energenie_power_sockets/test_switch.py b/tests/components/energenie_power_sockets/test_switch.py new file mode 100644 index 00000000000000..b98a3e07f56a9d --- /dev/null +++ b/tests/components/energenie_power_sockets/test_switch.py @@ -0,0 +1,134 @@ +"""Test the switch functionality.""" + +from unittest.mock import MagicMock + +from pyegps.exceptions import EgpsException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.energenie_power_sockets.const import DOMAIN +from homeassistant.components.homeassistant import ( + DOMAIN as HOME_ASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def _test_switch_on_off( + hass: HomeAssistant, entity_id: str, dev: MagicMock +) -> None: + """Call switch on/off service.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == STATE_OFF + + +async def _test_switch_on_exeception( + hass: HomeAssistant, entity_id: str, dev: MagicMock +) -> None: + """Call switch on service with USBError side effect.""" + dev.switch_on.side_effect = EgpsException + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + HOME_ASSISTANT_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": entity_id}, + blocking=True, + ) + dev.switch_on.side_effect = None + + +async def _test_switch_off_exeception( + hass: HomeAssistant, entity_id: str, dev: MagicMock +) -> None: + """Call switch off service with USBError side effect.""" + dev.switch_off.side_effect = EgpsException + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": entity_id}, + blocking=True, + ) + dev.switch_off.side_effect = None + + +async def _test_switch_update_exception( + hass: HomeAssistant, entity_id: str, dev: MagicMock +) -> None: + """Call switch update with USBError side effect.""" + dev.get_status.side_effect = EgpsException + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_UPDATE_ENTITY, + {"entity_id": entity_id}, + blocking=True, + ) + dev.get_status.side_effect = None + + +@pytest.mark.parametrize( + "entity_name", + [ + "mockedusbdevice_socket_0", + "mockedusbdevice_socket_1", + "mockedusbdevice_socket_2", + "mockedusbdevice_socket_3", + ], +) +async def test_switch_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + valid_config_entry: MockConfigEntry, + mock_get_device: MagicMock, + entity_name: str, + snapshot: SnapshotAssertion, +) -> None: + """Test setup and functionality of device switches.""" + + entry = valid_config_entry + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + assert entry.entry_id in hass.data[DOMAIN] + + state = hass.states.get(f"switch.{entity_name}") + assert state == snapshot + assert entity_registry.async_get(state.entity_id) == snapshot + + device_mock = mock_get_device.return_value + await _test_switch_on_off(hass, state.entity_id, device_mock) + await _test_switch_on_exeception(hass, state.entity_id, device_mock) + await _test_switch_off_exeception(hass, state.entity_id, device_mock) + await _test_switch_update_exception(hass, state.entity_id, device_mock) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index 85d02eff1536b5..5d6b9265760087 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -199,7 +199,7 @@ async def test_image_update_unavailable( # fritzbox becomes unavailable fc_class_mock().call_action_side_effect(ReadTimeout) async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("image.mock_title_guestwifi") assert state.state == STATE_UNKNOWN @@ -207,7 +207,7 @@ async def test_image_update_unavailable( # fritzbox is available again fc_class_mock().call_action_side_effect(None) async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("image.mock_title_guestwifi") assert state.state != STATE_UNKNOWN diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 4427fc6961edcc..37116e667199ae 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -134,7 +134,7 @@ async def test_sensor_update_fail( fc_class_mock().call_action_side_effect(FritzConnectionException) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=300)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) sensors = hass.states.async_all(SENSOR_DOMAIN) for sensor in sensors: diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 3828cedc67f316..3e1a2691f67131 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -104,7 +104,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 @@ -123,7 +123,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 @@ -146,7 +146,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_device_alarm") assert state diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index f254b2e0710b14..89e8d8357dd506 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -65,7 +65,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_template") assert state diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index a201eab3665215..073a67f22c1615 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -145,7 +145,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_preset") assert state @@ -203,7 +203,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert fritz().update_devices.call_count == 2 @@ -243,7 +243,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 @@ -386,7 +386,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert fritz().update_devices.call_count == 2 @@ -397,7 +397,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert fritz().update_devices.call_count == 3 @@ -422,7 +422,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_climate") assert state diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index b723ac97d069f6..6c301fc8f46c87 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -108,7 +108,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_climate") assert state diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index b750a2e927562b..45920c7c3eec73 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -237,7 +237,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 @@ -259,7 +259,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 @@ -294,7 +294,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_light") assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 48b769eaac2167..63d0b67d7f45de 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -87,7 +87,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 @@ -105,7 +105,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 @@ -128,7 +128,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_device_temperature") assert state diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 67393bc09a537b..417b355b39676a 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -151,7 +151,7 @@ async def test_update(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 2 assert fritz().login.call_count == 1 @@ -169,7 +169,7 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert fritz().update_devices.call_count == 4 assert fritz().login.call_count == 4 @@ -207,7 +207,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: next_update = dt_util.utcnow() + timedelta(seconds=200) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(f"{DOMAIN}.new_switch") assert state diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index af882e35751f37..052de4bf311dfa 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -88,11 +88,11 @@ def _read_char_raw(uuid: str, default: Any = SENTINEL): val = mock_read_char_raw[uuid] if isinstance(val, Exception): raise val - return val except KeyError: if default is SENTINEL: raise CharacteristicNotFound from KeyError return default + return val def _all_char(): return set(mock_read_char_raw.keys()) diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py index 76f1709bd756d9..d19262c3339c49 100644 --- a/tests/components/geo_rss_events/test_sensor.py +++ b/tests/components/geo_rss_events/test_sensor.py @@ -99,7 +99,7 @@ async def test_setup( # so no changes to entities. mock_feed.return_value.update.return_value = "OK_NO_DATA", None async_fire_time_changed(hass, utcnow + geo_rss_events.SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) all_states = hass.states.async_all() assert len(all_states) == 1 @@ -109,7 +109,7 @@ async def test_setup( # Simulate an update - empty data, removes all entities mock_feed.return_value.update.return_value = "ERROR", None async_fire_time_changed(hass, utcnow + 2 * geo_rss_events.SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) all_states = hass.states.async_all() assert len(all_states) == 1 diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index f0f1fe01796423..fd0df3be3a9c6e 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -1,5 +1,6 @@ """Tests for Glances.""" +from datetime import datetime from typing import Any MOCK_USER_INPUT: dict[str, Any] = { @@ -173,6 +174,8 @@ "uptime": "3 days, 10:25:20", } +MOCK_REFERENCE_DATE: datetime = datetime.fromisoformat("2024-02-13T14:13:12") + HA_SENSOR_DATA: dict[str, Any] = { "fs": { "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, @@ -207,4 +210,5 @@ "config": "UU", }, }, + "uptime": "3 days, 10:25:20", } diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index 23242f660714cc..cf74e91f613f8b 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -954,3 +954,50 @@ 'state': '30.7', }) # --- +# name: test_sensor_states[sensor.0_0_0_0_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.0_0_0_0_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'glances', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'test--uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[sensor.0_0_0_0_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '0.0.0.0 Uptime', + }), + 'context': , + 'entity_id': 'sensor.0_0_0_0_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-10T03:47:52+00:00', + }) +# --- diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index ebe8b75b618bd6..7dee47680ed8c0 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -1,25 +1,36 @@ """Tests for glances sensors.""" +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion from homeassistant.components.glances.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_USER_INPUT +from . import HA_SENSOR_DATA, MOCK_REFERENCE_DATE, MOCK_USER_INPUT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensor_states( - hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor states are correctly collected from library.""" + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) assert entity_entries @@ -28,3 +39,35 @@ async def test_sensor_states( assert hass.states.get(entity_entry.entity_id) == snapshot( name=f"{entity_entry.entity_id}-state" ) + + +async def test_uptime_variation( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_api: AsyncMock +) -> None: + """Test uptime small variation update.""" + + # Init with reference time + freezer.move_to(MOCK_REFERENCE_DATE) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT, entry_id="test") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + uptime_state = hass.states.get("sensor.0_0_0_0_uptime").state + + # Time change should not change uptime (absolute date) + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + uptime_state2 = hass.states.get("sensor.0_0_0_0_uptime").state + assert uptime_state2 == uptime_state + + mock_data = HA_SENSOR_DATA.copy() + mock_data["uptime"] = "1:25:20" + mock_api.return_value.get_ha_sensor_data = AsyncMock(return_value=mock_data) + + # Server has been restarted so therefore we should have a new state + freezer.move_to(MOCK_REFERENCE_DATE + timedelta(days=2)) + freezer.tick(delta=timedelta(seconds=120)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.0_0_0_0_uptime").state == "2024-02-15T12:49:52+00:00" diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 3f7fd91fed290b..492f1be1829251 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -344,7 +344,10 @@ def test_supported_features_string(caplog: pytest.LogCaptureFixture) -> None: State("test.entity_id", "on", {"supported_features": "invalid"}), ) assert entity.is_supported() is False - assert "Entity test.entity_id contains invalid supported_features value invalid" + assert ( + "Entity test.entity_id contains invalid supported_features value invalid" + in caplog.text + ) def test_request_data() -> None: diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 2d930599c24ab5..7c2fc8291d430b 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -334,7 +334,7 @@ async def test_conversation_agent( entry = entries[0] assert entry.state is ConfigEntryState.LOADED - agent = await conversation._get_agent_manager(hass).async_get_agent(entry.entry_id) + agent = await conversation.get_agent_manager(hass).async_get_agent(entry.entry_id) assert agent.supported_languages == SUPPORTED_LANGUAGE_CODES text1 = "tell me a joke" diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index b77fa14b4cfd5b..92e84b1fd39def 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -152,7 +152,7 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test GoogleGenerativeAIAgent.""" - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await conversation.get_agent_manager(hass).async_get_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" diff --git a/tests/components/google_mail/test_sensor.py b/tests/components/google_mail/test_sensor.py index e0b072d4b7d4c9..6f2f1a4ec329cd 100644 --- a/tests/components/google_mail/test_sensor.py +++ b/tests/components/google_mail/test_sensor.py @@ -46,7 +46,7 @@ async def test_sensors( ): next_update = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(SENSOR) assert state.state == result @@ -61,7 +61,7 @@ async def test_sensor_reauth_trigger( with patch(TOKEN, side_effect=RefreshError): next_update = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 13574bb2bb2f70..5d9cb86f9b6ead 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -87,7 +87,7 @@ def test_get_significant_states_minimal_response(hass_history) -> None: entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 0bbd913ce2b747..ce5c5a4b6c692e 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -96,7 +96,7 @@ def test_get_significant_states_minimal_response(legacy_hass_history) -> None: entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/hlk_sw16/test_config_flow.py b/tests/components/hlk_sw16/test_config_flow.py index e4770343114284..e8c0d36c81c6f7 100644 --- a/tests/components/hlk_sw16/test_config_flow.py +++ b/tests/components/hlk_sw16/test_config_flow.py @@ -32,9 +32,8 @@ async def status(self): if self.disconnect_callback: self.disconnect_callback() return await self.active_transaction - else: - self.active_transaction.set_result(True) - return self.active_transaction + self.active_transaction.set_result(True) + return self.active_transaction def stop(self): """Mock client stop.""" diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index e20fcb69d0070f..9a14198b1ef8e2 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -32,10 +32,9 @@ def entities_fixture( """Set up the test environment.""" if request.param == "entities_unique_id": return entities_unique_id(entity_registry) - elif request.param == "entities_no_unique_id": + if request.param == "entities_no_unique_id": return entities_no_unique_id(hass) - else: - raise RuntimeError("Invalid setup fixture") + raise RuntimeError("Invalid setup fixture") def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index ce81098f753cd1..fda9c9002400d5 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -104,6 +104,344 @@ 'state': '0.034', }) # --- +# name: test_sensor[sensor.test_mower_1_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.test_mower_1_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Error', + 'options': list([ + 'no_error', + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- # name: test_sensor[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -311,6 +649,78 @@ 'state': '11396', }) # --- +# name: test_sensor[sensor.test_mower_1_restricted_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'all_work_areas_completed', + 'daily_limit', + 'external', + 'fota', + 'frost', + 'none', + 'not_applicable', + 'park_override', + 'sensor', + 'week_schedule', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_restricted_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restricted reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'restricted_reason', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_restricted_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.test_mower_1_restricted_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Restricted reason', + 'options': list([ + 'all_work_areas_completed', + 'daily_limit', + 'external', + 'fota', + 'frost', + 'none', + 'not_applicable', + 'park_override', + 'sensor', + 'week_schedule', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_restricted_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'week_schedule', + }) +# --- # name: test_sensor[sensor.test_mower_1_total_charging_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index bc464b2ce789f4..6d4e8412ad3b95 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -94,6 +94,31 @@ async def test_statistics_not_available( assert state is None +async def test_error_sensor( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test error sensor.""" + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + for state, expected_state in [ + (None, "no_error"), + ("can_error", "can_error"), + ]: + values[TEST_MOWER_ID].mower.error_key = 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("sensor.test_mower_1_error") + assert state.state == expected_state + + async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 8608963413af95..aba9bd88c44a0b 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -164,6 +164,7 @@ async def test_receiving_message_successfully( assert data["folder"] == "INBOX" assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" + assert data["uid"] == "1" assert "Test body" in data["text"] assert ( valid_date @@ -213,6 +214,7 @@ async def test_receiving_message_with_invalid_encoding( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] == TEST_BADLY_ENCODED_CONTENT + assert data["uid"] == "1" @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @@ -251,6 +253,7 @@ async def test_receiving_message_no_subject_to_from( assert data["text"] == "Test body\r\n" assert data["headers"]["Return-Path"] == ("",) assert data["headers"]["Delivered-To"] == ("notify@example.com",) + assert data["uid"] == "1" @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index dba0822b1d86a0..6217a77903b0ef 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -57,8 +57,7 @@ def get_kodi_connection( """Get Kodi connection.""" if ws_port is None: return MockConnection() - else: - return MockWSConnection() + return MockWSConnection() class MockConnection: diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index b3fefe3ac67a19..2c24f4d0e75811 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -84,14 +84,12 @@ async def room_resolve_alias(self, room_alias: RoomAnyID): return RoomResolveAliasResponse( room_alias=room_alias, room_id=room_id, servers=[TEST_HOMESERVER] ) - else: - return RoomResolveAliasError(message=f"Could not resolve {room_alias}") + return RoomResolveAliasError(message=f"Could not resolve {room_alias}") async def join(self, room_id: RoomID): if room_id in TEST_JOINABLE_ROOMS.values(): return JoinResponse(room_id=room_id) - else: - return JoinError(message="Not allowed to join this room.") + return JoinError(message="Not allowed to join this room.") async def login(self, *args, **kwargs): if kwargs.get("password") == TEST_PASSWORD or kwargs.get("token") == TEST_TOKEN: @@ -101,9 +99,8 @@ async def login(self, *args, **kwargs): device_id="test_device", user_id=TEST_MXID, ) - else: - self.access_token = "" - return LoginError(message="LoginError", status_code="status_code") + self.access_token = "" + return LoginError(message="LoginError", status_code="status_code") async def logout(self, *args, **kwargs): self.access_token = "" @@ -115,19 +112,17 @@ async def whoami(self): return WhoamiResponse( user_id=TEST_MXID, device_id=TEST_DEVICE_ID, is_guest=False ) - else: - self.access_token = "" - return WhoamiError( - message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" - ) + self.access_token = "" + return WhoamiError( + message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" + ) async def room_send(self, *args, **kwargs): if not self.logged_in: raise LocalProtocolError if kwargs["room_id"] not in TEST_JOINABLE_ROOMS.values(): return ErrorResponse(message="Cannot send a message in this room.") - else: - return Response() + return Response() async def sync(self, *args, **kwargs): return None diff --git a/tests/components/maxcube/test_maxcube_binary_sensor.py b/tests/components/maxcube/test_maxcube_binary_sensor.py index cc86f389884902..32ec4e92ee112b 100644 --- a/tests/components/maxcube/test_maxcube_binary_sensor.py +++ b/tests/components/maxcube/test_maxcube_binary_sensor.py @@ -43,7 +43,7 @@ async def test_window_shuttler( windowshutter.is_open = False async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -68,12 +68,12 @@ async def test_window_shuttler_battery( windowshutter.battery = 1 # maxcube-api MAX_DEVICE_BATTERY_LOW async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(BATTERY_ENTITY_ID) assert state.state == STATE_ON # on means low windowshutter.battery = 0 # maxcube-api MAX_DEVICE_BATTERY_OK async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(BATTERY_ENTITY_ID) assert state.state == STATE_OFF # off means normal diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index cb4dc5106053b0..e1e7dc57c472ae 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -140,7 +140,7 @@ async def test_thermostat_set_hvac_mode_off( thermostat.valve_position = 0 async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.OFF @@ -168,8 +168,8 @@ async def test_thermostat_set_hvac_mode_heat( thermostat.mode = MAX_DEVICE_MODE_MANUAL async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.HEAT @@ -204,7 +204,7 @@ async def test_thermostat_set_temperature( thermostat.valve_position = 0 async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.AUTO @@ -248,7 +248,7 @@ async def test_thermostat_set_preset_on( thermostat.target_temperature = ON_TEMPERATURE async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.HEAT @@ -273,7 +273,7 @@ async def test_thermostat_set_preset_comfort( thermostat.target_temperature = thermostat.comfort_temperature async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.HEAT @@ -298,7 +298,7 @@ async def test_thermostat_set_preset_eco( thermostat.target_temperature = thermostat.eco_temperature async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.HEAT @@ -323,7 +323,7 @@ async def test_thermostat_set_preset_away( thermostat.target_temperature = thermostat.eco_temperature async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.HEAT @@ -348,7 +348,7 @@ async def test_thermostat_set_preset_boost( thermostat.target_temperature = thermostat.eco_temperature async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.AUTO @@ -401,7 +401,7 @@ async def test_wallthermostat_set_hvac_mode_heat( wallthermostat.target_temperature = MIN_TEMPERATURE async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(WALL_ENTITY_ID) assert state.state == HVACMode.HEAT @@ -425,7 +425,7 @@ async def test_wallthermostat_set_hvac_mode_auto( wallthermostat.target_temperature = 23.0 async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(WALL_ENTITY_ID) assert state.state == HVACMode.AUTO diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 2aa673d4010fa9..64a85897738568 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -125,7 +125,7 @@ async def test_site_cannot_update( future_time = utcnow() + timedelta(minutes=20) async_fire_time_changed(hass, future_time) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) weather = hass.states.get("weather.met_office_wavertree_daily") assert weather.state == STATE_UNAVAILABLE @@ -297,7 +297,7 @@ async def test_forecast_service( # Trigger data refetch freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert wavertree_data["wavertree_daily_mock"].call_count == 2 assert wavertree_data["wavertree_hourly_mock"].call_count == 1 @@ -324,7 +324,7 @@ async def test_forecast_service( freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) response = await hass.services.async_call( WEATHER_DOMAIN, @@ -412,7 +412,7 @@ async def test_forecast_subscription( freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) msg = await client.receive_json() assert msg["id"] == subscription_id @@ -430,6 +430,6 @@ async def test_forecast_subscription( ) freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) msg = await client.receive_json() assert msg["success"] diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 47ddc038f692ef..89dc37fd781398 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -88,7 +88,7 @@ async def test_device_trackers( WIRELESS_DATA.append(DEVICE_2_WIRELESS) async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) device_2 = hass.states.get("device_tracker.device_2") assert device_2 @@ -101,7 +101,7 @@ async def test_device_trackers( del WIRELESS_DATA[1] # device 2 is removed from wireless list with freeze_time(utcnow() + timedelta(minutes=4)): async_fire_time_changed(hass, utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) device_2 = hass.states.get("device_tracker.device_2") assert device_2 @@ -110,7 +110,7 @@ async def test_device_trackers( # test state changes to away if last_seen past consider_home_interval with freeze_time(utcnow() + timedelta(minutes=6)): async_fire_time_changed(hass, utcnow() + timedelta(minutes=6)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) device_2 = hass.states.get("device_tracker.device_2") assert device_2 @@ -266,7 +266,7 @@ async def test_update_failed(hass: HomeAssistant, mock_device_registry_devices) mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect ): async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) device_1 = hass.states.get("device_tracker.device_1") assert device_1 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 4a5f472221f175..9d941685c097dc 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -24,10 +24,6 @@ from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE from tests.common import async_capture_events, async_mock_service -from tests.components.conversation.conftest import mock_agent - -# To avoid autoflake8 removing the import -mock_agent = mock_agent @pytest.fixture @@ -1027,14 +1023,18 @@ async def test_reregister_sensor( async def test_webhook_handle_conversation_process( - hass: HomeAssistant, homeassistant, create_registrations, webhook_client, mock_agent + hass: HomeAssistant, + homeassistant, + create_registrations, + webhook_client, + mock_conversation_agent, ) -> None: """Test that we can converse.""" webhook_client.server.app.router._frozen = False with patch( - "homeassistant.components.conversation.AgentManager.async_get_agent", - return_value=mock_agent, + "homeassistant.components.conversation.agent_manager.AgentManager.async_get_agent", + return_value=mock_conversation_agent, ): resp = await webhook_client.post( "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py index a0afd37f3b225f..f7d88692cf5b72 100644 --- a/tests/components/monoprice/test_media_player.py +++ b/tests/components/monoprice/test_media_player.py @@ -183,7 +183,7 @@ async def test_service_calls_with_entity_id(hass: HomeAssistant) -> None: # Restoring other media player to its previous state # The zone should not be restored await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_2_ID}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Checking that values were not (!) restored state = hass.states.get(ZONE_1_ID) @@ -193,7 +193,7 @@ async def test_service_calls_with_entity_id(hass: HomeAssistant) -> None: # Restoring media player to its previous state await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -226,7 +226,7 @@ async def test_service_calls_with_all_entities(hass: HomeAssistant) -> None: # Restoring media player to its previous state await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": "all"}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -259,7 +259,7 @@ async def test_service_calls_without_relevant_entities(hass: HomeAssistant) -> N # Restoring media player to its previous state await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": "light.demo"}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -273,7 +273,7 @@ async def test_restore_without_snapshort(hass: HomeAssistant) -> None: with patch.object(MockMonoprice, "restore_zone") as method_call: await _call_monoprice_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert not method_call.called @@ -295,7 +295,7 @@ async def test_update(hass: HomeAssistant) -> None: monoprice.set_volume(11, 38) await async_update_entity(hass, ZONE_1_ID) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -321,7 +321,7 @@ async def test_failed_update(hass: HomeAssistant) -> None: with patch.object(MockMonoprice, "zone_status", side_effect=SerialException): await async_update_entity(hass, ZONE_1_ID) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -347,7 +347,7 @@ async def test_empty_update(hass: HomeAssistant) -> None: with patch.object(MockMonoprice, "zone_status", return_value=None): await async_update_entity(hass, ZONE_1_ID) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -418,7 +418,7 @@ async def test_unknown_source(hass: HomeAssistant) -> None: monoprice.set_source(11, 5) await async_update_entity(hass, ZONE_1_ID) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 1224fce098d3b7..821a3f911b75f9 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -148,74 +148,6 @@ async def test_preset_none_in_preset_modes( assert "preset_modes must not include preset mode 'none'" in caplog.text -@pytest.mark.parametrize( - ("hass_config", "parameter"), - [ - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"away_mode_command_topic": "away-mode-command-topic"},), - ), - "away_mode_command_topic", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"away_mode_state_topic": "away-mode-state-topic"},), - ), - "away_mode_state_topic", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"away_mode_state_template": "{{ value_json }}"},), - ), - "away_mode_state_template", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"hold_mode_command_topic": "hold-mode-command-topic"},), - ), - "hold_mode_command_topic", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"hold_mode_command_template": "hold-mode-command-template"},), - ), - "hold_mode_command_template", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"hold_mode_state_topic": "hold-mode-state-topic"},), - ), - "hold_mode_state_topic", - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ({"hold_mode_state_template": "{{ value_json }}"},), - ), - "hold_mode_state_template", - ), - ], -) -async def test_preset_modes_deprecation_guard( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter: str -) -> None: - """Test the configuration for invalid legacy parameters.""" - assert f"[{parameter}] is an invalid option for [mqtt]. Check: mqtt->mqtt->climate->0->{parameter}" - - @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index ffe69ca46288a1..6dd9dc73973acc 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -229,7 +229,7 @@ async def test_message_history_pruning( assert isinstance(result.conversation_id, str) conversation_ids.append(result.conversation_id) - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await conversation.get_agent_manager(hass).async_get_agent( mock_config_entry.entry_id ) assert isinstance(agent, ollama.OllamaAgent) @@ -284,7 +284,7 @@ async def test_message_history_unlimited( result.response.response_type == intent.IntentResponseType.ACTION_DONE ), result - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await conversation.get_agent_manager(hass).async_get_agent( mock_config_entry.entry_id ) assert isinstance(agent, ollama.OllamaAgent) @@ -340,7 +340,7 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test OllamaAgent.""" - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await conversation.get_agent_manager(hass).async_get_agent( mock_config_entry.entry_id ) assert agent.supported_languages == MATCH_ALL diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3a8db2a71c0a8a..c94fdcebcdefca 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -194,7 +194,7 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test OpenAIAgent.""" - agent = await conversation._get_agent_manager(hass).async_get_agent( + agent = await conversation.get_agent_manager(hass).async_get_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py index 668416c6dcba52..ab23de9937b105 100644 --- a/tests/components/opensky/__init__.py +++ b/tests/components/opensky/__init__.py @@ -1,20 +1,12 @@ """Opensky tests.""" -from unittest.mock import patch +from homeassistant.core import HomeAssistant -from python_opensky import StatesResponse +from tests.common import MockConfigEntry -from tests.common import load_json_object_fixture - -def patch_setup_entry() -> bool: - """Patch interface.""" - return patch( - "homeassistant.components.opensky.async_setup_entry", return_value=True - ) - - -def get_states_response_fixture(fixture: str) -> StatesResponse: - """Return the states response from json.""" - states_json = load_json_object_fixture(fixture) - return StatesResponse.from_api(states_json) +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index 835543b632f812..665fdd90e69fd3 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,9 +1,10 @@ """Configure tests for the OpenSky integration.""" -from collections.abc import Awaitable, Callable -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest +from python_opensky import StatesResponse from homeassistant.components.opensky.const import ( CONF_ALTITUDE, @@ -17,14 +18,18 @@ CONF_RADIUS, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from . import get_states_response_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -from tests.common import MockConfigEntry -ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.opensky.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry @pytest.fixture(name="config_entry") @@ -81,19 +86,22 @@ def mock_config_entry_authenticated() -> MockConfigEntry: ) -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, -) -> Callable[[MockConfigEntry], Awaitable[None]]: - """Fixture for setting up the component.""" - - async def func(mock_config_entry: MockConfigEntry) -> None: - mock_config_entry.add_to_hass(hass) - with patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states.json"), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - return func +@pytest.fixture +async def opensky_client() -> Generator[AsyncMock, None, None]: + """Mock the OpenSky client.""" + with ( + patch( + "homeassistant.components.opensky.OpenSky", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.opensky.config_flow.OpenSky", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_states.return_value = StatesResponse.from_api( + load_json_object_fixture("states.json", DOMAIN) + ) + client.is_authenticated = False + yield client diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index c3ae876d36e11a..8168511a16c67d 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -1,7 +1,7 @@ """Test OpenSky config flow.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from python_opensky.exceptions import OpenSkyUnauthenticatedError @@ -23,39 +23,36 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import get_states_response_fixture, patch_setup_entry -from .conftest import ComponentSetup - from tests.common import MockConfigEntry +from tests.components.opensky import setup_integration -async def test_full_user_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: """Test the full user configuration flow.""" - with patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_RADIUS: 10, - CONF_LATITUDE: 0.0, - CONF_LONGITUDE: 0.0, - CONF_ALTITUDE: 0, - }, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "OpenSky" - assert result["data"] == { + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10, CONF_LATITUDE: 0.0, CONF_LONGITUDE: 0.0, - } - assert result["options"] == { - CONF_ALTITUDE: 0.0, - CONF_RADIUS: 10.0, - } + CONF_ALTITUDE: 0, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenSky" + assert result["data"] == { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } + assert result["options"] == { + CONF_ALTITUDE: 0.0, + CONF_RADIUS: 10.0, + } @pytest.mark.parametrize( @@ -79,92 +76,77 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: ) async def test_options_flow_failures( hass: HomeAssistant, - setup_integration: ComponentSetup, + mock_setup_entry: AsyncMock, + opensky_client: AsyncMock, config_entry: MockConfigEntry, user_input: dict[str, Any], error: str, ) -> None: """Test load and unload entry.""" - await setup_integration(config_entry) - entry = hass.config_entries.async_entries(DOMAIN)[0] - with patch( - "python_opensky.OpenSky.authenticate", - side_effect=OpenSkyUnauthenticatedError(), - ): - result = await hass.config_entries.options.async_init(entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_RADIUS: 10000, **user_input}, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"]["base"] == error - with ( - patch("python_opensky.OpenSky.authenticate"), - patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states_1.json"), - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_RADIUS: 10000, - CONF_USERNAME: "homeassistant", - CONF_PASSWORD: "secret", - CONF_CONTRIBUTING_USER: True, - }, - ) - await hass.async_block_till_done() + await setup_integration(hass, config_entry) + + opensky_client.authenticate.side_effect = OpenSkyUnauthenticatedError + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 10000, **user_input}, + ) + await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == { + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["base"] == error + opensky_client.authenticate.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ CONF_RADIUS: 10000, CONF_USERNAME: "homeassistant", CONF_PASSWORD: "secret", CONF_CONTRIBUTING_USER: True, - } + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } async def test_options_flow( hass: HomeAssistant, - setup_integration: ComponentSetup, + mock_setup_entry: AsyncMock, + opensky_client: AsyncMock, config_entry: MockConfigEntry, ) -> None: """Test options flow.""" - await setup_integration(config_entry) - entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await hass.config_entries.options.async_init(entry.entry_id) + await setup_integration(hass, config_entry) + result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() - with ( - patch("python_opensky.OpenSky.authenticate"), - patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states_1.json"), - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_RADIUS: 10000, - CONF_USERNAME: "homeassistant", - CONF_PASSWORD: "secret", - CONF_CONTRIBUTING_USER: True, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"] == { + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ CONF_RADIUS: 10000, CONF_USERNAME: "homeassistant", CONF_PASSWORD: "secret", CONF_CONTRIBUTING_USER: True, - } + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py index a9e1668d026d90..f5acf7479a2c80 100644 --- a/tests/components/opensky/test_init.py +++ b/tests/components/opensky/test_init.py @@ -2,67 +2,59 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from python_opensky import OpenSkyError from python_opensky.exceptions import OpenSkyUnauthenticatedError -from homeassistant.components.opensky.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .conftest import ComponentSetup from tests.common import MockConfigEntry +from tests.components.opensky import setup_integration async def test_load_unload_entry( hass: HomeAssistant, - setup_integration: ComponentSetup, config_entry: MockConfigEntry, + opensky_client: AsyncMock, ) -> None: """Test load and unload entry.""" - await setup_integration(config_entry) - entry = hass.config_entries.async_entries(DOMAIN)[0] + await setup_integration(hass, config_entry) - state = hass.states.get("sensor.opensky") - assert state + assert config_entry.state is ConfigEntryState.LOADED - await hass.config_entries.async_remove(entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.opensky") - assert not state + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_load_entry_failure( hass: HomeAssistant, config_entry: MockConfigEntry, + opensky_client: AsyncMock, ) -> None: """Test failure while loading.""" + opensky_client.get_states.side_effect = OpenSkyError() config_entry.add_to_hass(hass) - with patch( - "python_opensky.OpenSky.get_states", - side_effect=OpenSkyError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.state == ConfigEntryState.SETUP_RETRY + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY async def test_load_entry_authentication_failure( hass: HomeAssistant, config_entry_authenticated: MockConfigEntry, + opensky_client: AsyncMock, ) -> None: """Test auth failure while loading.""" + opensky_client.authenticate.side_effect = OpenSkyUnauthenticatedError() config_entry_authenticated.add_to_hass(hass) - with patch( - "python_opensky.OpenSky.authenticate", - side_effect=OpenSkyUnauthenticatedError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.state == ConfigEntryState.SETUP_RETRY + assert not await hass.config_entries.async_setup( + config_entry_authenticated.entry_id + ) + await hass.async_block_till_done() + + assert config_entry_authenticated.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index df4faaa3e4adb4..801980ec5b97d8 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -1,31 +1,35 @@ """OpenSky sensor tests.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory +from python_opensky import StatesResponse from syrupy import SnapshotAssertion from homeassistant.components.opensky.const import ( + DOMAIN, EVENT_OPENSKY_ENTRY, EVENT_OPENSKY_EXIT, ) from homeassistant.core import Event, HomeAssistant -from . import get_states_response_fixture -from .conftest import ComponentSetup - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, +) +from tests.components.opensky import setup_integration async def test_sensor( hass: HomeAssistant, config_entry: MockConfigEntry, - setup_integration: ComponentSetup, snapshot: SnapshotAssertion, + opensky_client: AsyncMock, ): """Test setup sensor.""" - await setup_integration(config_entry) + await setup_integration(hass, config_entry) state = hass.states.get("sensor.opensky") assert state == snapshot @@ -42,11 +46,11 @@ async def event_listener(event: Event) -> None: async def test_sensor_altitude( hass: HomeAssistant, config_entry_altitude: MockConfigEntry, - setup_integration: ComponentSetup, + opensky_client: AsyncMock, snapshot: SnapshotAssertion, ): """Test setup sensor with a set altitude.""" - await setup_integration(config_entry_altitude) + await setup_integration(hass, config_entry_altitude) state = hass.states.get("sensor.opensky") assert state == snapshot @@ -55,12 +59,12 @@ async def test_sensor_altitude( async def test_sensor_updating( hass: HomeAssistant, config_entry: MockConfigEntry, + opensky_client: AsyncMock, freezer: FrozenDateTimeFactory, - setup_integration: ComponentSetup, snapshot: SnapshotAssertion, ): """Test updating sensor.""" - await setup_integration(config_entry) + await setup_integration(hass, config_entry) events = [] @@ -77,13 +81,11 @@ async def skip_time_and_check_events() -> None: assert events == snapshot - with patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states_1.json"), - ): - await skip_time_and_check_events() - with patch( - "python_opensky.OpenSky.get_states", - return_value=get_states_response_fixture("opensky/states.json"), - ): - await skip_time_and_check_events() + opensky_client.get_states.return_value = StatesResponse.from_api( + load_json_object_fixture("states_1.json", DOMAIN) + ) + await skip_time_and_check_events() + opensky_client.get_states.return_value = StatesResponse.from_api( + load_json_object_fixture("states.json", DOMAIN) + ) + await skip_time_and_check_events() diff --git a/tests/components/panasonic_viera/test_media_player.py b/tests/components/panasonic_viera/test_media_player.py index 1203bf1ed51605..dab56542e6a22e 100644 --- a/tests/components/panasonic_viera/test_media_player.py +++ b/tests/components/panasonic_viera/test_media_player.py @@ -23,7 +23,7 @@ async def test_media_player_handle_URLerror( mock_remote.get_mute = Mock(side_effect=URLError(None, None)) async_fire_time_changed(hass, utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state_tv = hass.states.get("media_player.panasonic_viera_tv") assert state_tv.state == STATE_UNAVAILABLE @@ -41,7 +41,7 @@ async def test_media_player_handle_HTTPError( mock_remote.get_mute = Mock(side_effect=HTTPError(None, 400, None, None, None)) async_fire_time_changed(hass, utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state_tv = hass.states.get("media_player.panasonic_viera_tv") assert state_tv.state == STATE_OFF diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index a6d17233450010..d44bc942290190 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -208,7 +208,7 @@ async def test_update_unavailable(projector_from_address, hass: HomeAssistant) - projector_from_address.side_effect = socket.timeout async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.test") assert state.state == "unavailable" @@ -237,7 +237,7 @@ async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: mocked_projector.get_power.side_effect = ProjectorError("unavailable time") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.test") assert state.state == "off" diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index aec20bc4a0b347..878300bddb45c2 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -4,6 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity from tests.common import MockConfigEntry @@ -45,9 +46,7 @@ async def test_anna_climate_binary_sensor_change( assert state assert state.state == STATE_ON - await hass.helpers.entity_component.async_update_entity( - "binary_sensor.opentherm_dhw_state" - ) + await async_update_entity(hass, "binary_sensor.opentherm_dhw_state") state = hass.states.get("binary_sensor.opentherm_dhw_state") assert state diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 1140dc74849dc0..3cade46534731d 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -332,7 +332,7 @@ def __repr__(self): caplog.clear() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "No new object growth found" in caplog.text fake_object2 = FakeObject() @@ -344,7 +344,7 @@ def __repr__(self): caplog.clear() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "New object FakeObject (1/2)" in caplog.text many_objects = [FakeObject() for _ in range(30)] @@ -352,7 +352,7 @@ def __repr__(self): caplog.clear() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "New object FakeObject (2/30)" in caplog.text assert "New objects overflowed by {'FakeObject': 25}" in caplog.text @@ -362,7 +362,7 @@ def __repr__(self): caplog.clear() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=41)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "FakeObject" not in caplog.text assert "No new object growth found" not in caplog.text @@ -370,7 +370,7 @@ def __repr__(self): await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=51)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "FakeObject" not in caplog.text assert "No new object growth found" not in caplog.text diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index 875b049d8c3f06..6adcad03016f5d 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -234,6 +234,7 @@ async def test_media_attributes_are_fetched(hass: HomeAssistant) -> None: with patch(mock_func, return_value=mock_result) as mock_fetch: await mock_ddp_response(hass, MOCK_STATUS_PLAYING) + await hass.async_block_till_done(wait_background_tasks=True) mock_state = hass.states.get(mock_entity_id) mock_attrs = dict(mock_state.attributes) @@ -255,6 +256,7 @@ async def test_media_attributes_are_fetched(hass: HomeAssistant) -> None: with patch(mock_func, return_value=mock_result) as mock_fetch_app: await mock_ddp_response(hass, MOCK_STATUS_PLAYING) + await hass.async_block_till_done(wait_background_tasks=True) mock_state = hass.states.get(mock_entity_id) mock_attrs = dict(mock_state.attributes) diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 8e317e2163e829..504d61a0d8d13a 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -78,7 +78,7 @@ async def test_execute_with_data( """ hass.async_add_executor_job(execute, hass, "test.py", source, {"name": "paulus"}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state("test.entity", "paulus") @@ -96,7 +96,7 @@ async def test_execute_warns_print( """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "Don't use print() inside scripts." in caplog.text @@ -111,7 +111,7 @@ async def test_execute_logging( """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "Logging from inside script" in caplog.text @@ -126,7 +126,7 @@ async def test_execute_compile_error( """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "Error loading script test.py" in caplog.text @@ -140,8 +140,8 @@ async def test_execute_runtime_error( raise Exception('boom') """ - hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_block_till_done(wait_background_tasks=True) assert "Error executing script" in caplog.text @@ -153,7 +153,7 @@ async def test_execute_runtime_error_with_response(hass: HomeAssistant) -> None: """ task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert type(task.exception()) == HomeAssistantError assert "Error executing script (Exception): boom" in str(task.exception()) @@ -168,7 +168,7 @@ async def test_accessing_async_methods( hass.async_stop() """ - hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done() assert "Not allowed to access async methods" in caplog.text @@ -181,7 +181,7 @@ async def test_accessing_async_methods_with_response(hass: HomeAssistant) -> Non """ task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert type(task.exception()) == ServiceValidationError assert "Not allowed to access async methods" in str(task.exception()) @@ -198,7 +198,7 @@ async def test_using_complex_structures( logger.info('Logging from inside script: %s %s' % (mydict["a"], mylist[2])) """ - hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done() assert "Logging from inside script: 1 3" in caplog.text @@ -217,7 +217,7 @@ async def test_accessing_forbidden_methods( "time.tzset()": "TimeWrapper.tzset", }.items(): caplog.records.clear() - hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done() assert f"Not allowed to access {name}" in caplog.text @@ -231,7 +231,7 @@ async def test_accessing_forbidden_methods_with_response(hass: HomeAssistant) -> "time.tzset()": "TimeWrapper.tzset", }.items(): task = hass.async_add_executor_job(execute, hass, "test.py", source, {}, True) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert type(task.exception()) == ServiceValidationError assert f"Not allowed to access {name}" in str(task.exception()) @@ -244,7 +244,7 @@ async def test_iterating(hass: HomeAssistant) -> None: hass.states.set('hello.{}'.format(i), 'world') """ - hass.async_add_executor_job(execute, hass, "test.py", source, {}) + await hass.async_add_executor_job(execute, hass, "test.py", source, {}) await hass.async_block_till_done() assert hass.states.is_state("hello.1", "world") @@ -279,7 +279,7 @@ async def test_unpacking_sequence( """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state("hello.a", "1") assert hass.states.is_state("hello.b", "2") @@ -302,7 +302,7 @@ async def test_execute_sorted( hass.states.set('hello.c', a[2]) """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state("hello.a", "1") assert hass.states.is_state("hello.b", "2") @@ -325,7 +325,7 @@ async def test_exposed_modules( """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state("module.time", "1986") assert hass.states.is_state("module.time_strptime", "12:34") @@ -351,7 +351,7 @@ def b(): b() """ hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state("hello.a", "one") assert hass.states.is_state("hello.b", "two") @@ -517,7 +517,7 @@ async def test_sleep_warns_one( with patch("homeassistant.components.python_script.time.sleep"): hass.async_add_executor_job(execute, hass, "test.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert caplog.text.count("time.sleep") == 1 @@ -664,7 +664,7 @@ async def test_augmented_assignment_operations(hass: HomeAssistant) -> None: """ hass.async_add_executor_job(execute, hass, "aug_assign.py", source, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("hello.a").state == str(((10 + 20) * 5) - 8) assert hass.states.get("hello.b").state == ("foo" + "bar") * 2 @@ -686,5 +686,5 @@ async def test_prohibited_augmented_assignment_operations( ) -> None: """Test that prohibited augmented assignment operations raise an error.""" hass.async_add_executor_job(execute, hass, "aug_assign_prohibited.py", case, {}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert error in caplog.text diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d16a6856399159..04204cf84a684d 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -554,7 +554,7 @@ def test_get_significant_states_minimal_response( entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index cbe4c3ac5c83e4..0aaf1ebb094b29 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -328,7 +328,7 @@ def test_get_significant_states_minimal_response( entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index b926aa1903bfe1..9bb6d70b1258e0 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -327,7 +327,7 @@ def test_get_significant_states_minimal_response( entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 98ed6089de6c61..a72345e71bd462 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -556,7 +556,7 @@ def test_get_significant_states_minimal_response( entity_states = states[entity_id] for state_idx in range(1, len(entity_states)): input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( + orig_last_changed = json.dumps( process_timestamp(input_state.last_changed), cls=JSONEncoder, ).replace('"', "") diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 67f6aa5e6f6d06..8f09c4a2e5400f 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -57,8 +57,7 @@ async def create_rflink_connection(*args, **kwargs): if fail: raise ConnectionRefusedError - else: - return transport, protocol + return transport, protocol mock_create = Mock(wraps=create_rflink_connection) monkeypatch.setattr( diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index aadea6f0ba1cc7..2c866586c6c519 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -87,7 +87,7 @@ async def test_history( assert front_door_last_activity_state.state == "2017-03-05T15:03:40+00:00" ingress_last_activity_state = hass.states.get("sensor.ingress_last_activity") - assert ingress_last_activity_state.state == "unknown" + assert ingress_last_activity_state.state == "2024-02-02T11:21:24+00:00" async def test_only_chime_devices( diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index f874b92305bef1..db4f3f0e41f754 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -200,7 +200,7 @@ async def test_setup_websocket_2( next_update = mock_now + timedelta(minutes=5) freezer.move_to(next_update) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state @@ -225,7 +225,7 @@ async def test_setup_encrypted_websocket( next_update = mock_now + timedelta(minutes=5) freezer.move_to(next_update) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state @@ -242,7 +242,7 @@ async def test_update_on( next_update = mock_now + timedelta(minutes=5) freezer.move_to(next_update) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -262,7 +262,7 @@ async def test_update_off( next_update = mock_now + timedelta(minutes=5) freezer.move_to(next_update) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE @@ -290,7 +290,7 @@ async def test_update_off_ws_no_power_state( next_update = mock_now + timedelta(minutes=5) freezer.move_to(next_update) async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index 4673f263c8cf05..97f11577b864d8 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -22,7 +22,7 @@ async def test_keypad_disabled_binary_sensor( # Make the coordinator refresh data. async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None @@ -43,7 +43,7 @@ async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( # Make the coordinator refresh data. async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 0972aa97033bb2..5b26da7b27e07f 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -59,7 +59,7 @@ async def test_changed_by( # Make the coordinator refresh data. async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_lock.last_changed_by.assert_called_once_with() lock_device = hass.states.get("lock.vault_door") diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 41da2eb9a79101..4d9c2b732dc921 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -261,7 +261,7 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: mocker.payload = "test_scrape_sensor_no_data" async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.ha_version") assert state is not None @@ -541,7 +541,7 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: hass, dt_util.utcnow() + timedelta(minutes=10), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.get_values_with_template") assert state.state == "Current Version: 2021.12.10" @@ -555,7 +555,7 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: hass, dt_util.utcnow() + timedelta(minutes=20), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.get_values_with_template") assert state.state == STATE_UNAVAILABLE @@ -568,7 +568,7 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: hass, dt_util.utcnow() + timedelta(minutes=30), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.get_values_with_template") assert state.state == "Current Version: 2021.12.10" @@ -608,7 +608,7 @@ async def test_availability( hass.states.async_set("sensor.input1", "on") freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.current_version") assert state.state == "2021.12.10" @@ -618,7 +618,7 @@ async def test_availability( freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.current_version") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/select/common.py b/tests/components/select/common.py new file mode 100644 index 00000000000000..c2a401a038be71 --- /dev/null +++ b/tests/components/select/common.py @@ -0,0 +1,23 @@ +"""Common helpers for select entity component tests.""" + +from homeassistant.components.select import SelectEntity + +from tests.common import MockEntity + + +class MockSelectEntity(MockEntity, SelectEntity): + """Mock Select class.""" + + @property + def current_option(self): + """Return the current option of this select.""" + return self._handle("current_option") + + @property + def options(self) -> list: + """Return the list of available options of this select.""" + return self._handle("options") + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self._values["current_option"] = option diff --git a/tests/components/select/conftest.py b/tests/components/select/conftest.py new file mode 100644 index 00000000000000..700749f9abae86 --- /dev/null +++ b/tests/components/select/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for the select entity component tests.""" + +import pytest + +from tests.components.select.common import MockSelectEntity + + +@pytest.fixture +def mock_select_entities() -> list[MockSelectEntity]: + """Return a list of mock select entities.""" + return [ + MockSelectEntity( + name="select 1", + unique_id="unique_select_1", + options=["option 1", "option 2", "option 3"], + current_option="option 1", + ), + MockSelectEntity( + name="select 2", + unique_id="unique_select_2", + options=["option 1", "option 2", "option 3"], + current_option=None, + ), + ] diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index b135a6e1ab0510..a5be7921fcdd24 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -21,6 +21,8 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component +from tests.common import setup_test_component_platform + class MockSelectEntity(SelectEntity): """Mock SelectEntity to use in tests.""" @@ -91,11 +93,11 @@ async def test_select(hass: HomeAssistant) -> None: async def test_custom_integration_and_validation( - hass: HomeAssistant, enable_custom_integrations: None + hass: HomeAssistant, + mock_select_entities: list[MockSelectEntity], ) -> None: """Test we can only select valid options.""" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() + setup_test_component_platform(hass, DOMAIN, mock_select_entities) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 2dc9012d863759..348b1115a6fd47 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -126,6 +126,18 @@ def register_entity( return f"{domain}.{object_id}" +def get_entity( + hass: HomeAssistant, + domain: str, + unique_id: str, +) -> str | None: + """Get Shelly entity.""" + entity_registry = async_get(hass) + return entity_registry.async_get_entity_id( + domain, DOMAIN, f"{MOCK_MAC}-{unique_id}" + ) + + def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: """Return entity state.""" entity = hass.states.get(entity_id) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 9a73252ca6caa4..3cd27101f76c43 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -169,6 +169,9 @@ 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"}, + "light:1": {"name": "test light_1"}, + "light:2": {"name": "test light_2"}, + "light:3": {"name": "test light_3"}, "rgb:0": {"name": "test rgb_0"}, "rgbw:0": {"name": "test rgbw_0"}, "switch:0": {"name": "test switch_0"}, @@ -225,6 +228,9 @@ 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}, + "light:1": {"output": True, "brightness": 53.0}, + "light:2": {"output": True, "brightness": 53.0}, + "light:3": {"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}, diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index cca318c364ddf1..2c464a8c39c48a 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -29,6 +29,7 @@ ColorMode, LightEntityFeature, ) +from homeassistant.components.shelly.const import SHELLY_PLUS_RGBW_CHANNELS from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, @@ -38,7 +39,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, mutate_rpc_device_status +from . import get_entity, init_integration, mutate_rpc_device_status, register_entity from .conftest import mock_white_light_set_state RELAY_BLOCK_ID = 0 @@ -587,7 +588,8 @@ async def test_rpc_device_rgb_profile( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device in RGB profile.""" - monkeypatch.delitem(mock_rpc_device.status, "light:0") + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") monkeypatch.delitem(mock_rpc_device.status, "rgbw:0") entity_id = "light.test_rgb_0" await init_integration(hass, 2) @@ -633,7 +635,8 @@ async def test_rpc_device_rgbw_profile( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device in RGBW profile.""" - monkeypatch.delitem(mock_rpc_device.status, "light:0") + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") monkeypatch.delitem(mock_rpc_device.status, "rgb:0") entity_id = "light.test_rgbw_0" await init_integration(hass, 2) @@ -673,3 +676,82 @@ async def test_rpc_device_rgbw_profile( entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-rgbw:0" + + +async def test_rpc_rgbw_device_light_mode_remove_others( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly RPC RGBW device in light mode removes RGB/RGBW entities.""" + # register lights + monkeypatch.delitem(mock_rpc_device.status, "rgb:0") + monkeypatch.delitem(mock_rpc_device.status, "rgbw:0") + register_entity(hass, LIGHT_DOMAIN, "test_rgb_0", "rgb:0") + register_entity(hass, LIGHT_DOMAIN, "test_rgbw_0", "rgbw:0") + + # verify RGB & RGBW entities created + assert get_entity(hass, LIGHT_DOMAIN, "rgb:0") is not None + assert get_entity(hass, LIGHT_DOMAIN, "rgbw:0") is not None + + # init to remove RGB & RGBW + await init_integration(hass, 2) + + # verify we have 4 lights + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + entity_id = f"light.test_light_{i}" + assert hass.states.get(entity_id).state == STATE_ON + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"123456789ABC-light:{i}" + + # verify RGB & RGBW entities removed + assert get_entity(hass, LIGHT_DOMAIN, "rgb:0") is None + assert get_entity(hass, LIGHT_DOMAIN, "rgbw:0") is None + + +@pytest.mark.parametrize( + ("active_mode", "removed_mode"), + [ + ("rgb", "rgbw"), + ("rgbw", "rgb"), + ], +) +async def test_rpc_rgbw_device_rgb_w_modes_remove_others( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + active_mode: str, + removed_mode: str, +) -> None: + """Test Shelly RPC RGBW device in RGB/W modes other lights.""" + removed_key = f"{removed_mode}:0" + + # register lights + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + monkeypatch.delitem(mock_rpc_device.status, f"light:{i}") + entity_id = f"light.test_light_{i}" + register_entity(hass, LIGHT_DOMAIN, entity_id, f"light:{i}") + monkeypatch.delitem(mock_rpc_device.status, f"{removed_mode}:0") + register_entity(hass, LIGHT_DOMAIN, f"test_{removed_key}", removed_key) + + # verify lights entities created + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + assert get_entity(hass, LIGHT_DOMAIN, f"light:{i}") is not None + assert get_entity(hass, LIGHT_DOMAIN, removed_key) is not None + + await init_integration(hass, 2) + + # verify we have RGB/w light + entity_id = f"light.test_{active_mode}_0" + assert hass.states.get(entity_id).state == STATE_ON + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"123456789ABC-{active_mode}:0" + + # verify light & RGB/W entities removed + for i in range(SHELLY_PLUS_RGBW_CHANNELS): + assert get_entity(hass, LIGHT_DOMAIN, f"light:{i}") is None + assert get_entity(hass, LIGHT_DOMAIN, removed_key) is None diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 4bd9dee930c546..b1496d18d9309d 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -53,7 +53,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge().get_overview.return_value = mock_overview_data freezer.tick(OVERVIEW_UPDATE_DELAY) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.solaredge_lifetime_energy") assert state assert state.state == str(mock_overview_data["overview"]["lifeTimeData"]["energy"]) @@ -63,7 +63,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge().get_overview.return_value = mock_overview_data freezer.tick(OVERVIEW_UPDATE_DELAY) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.solaredge_lifetime_energy") assert state @@ -74,7 +74,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge().get_overview.return_value = mock_overview_data freezer.tick(OVERVIEW_UPDATE_DELAY) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.solaredge_lifetime_energy") assert state @@ -85,7 +85,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge().get_overview.return_value = mock_overview_data freezer.tick(OVERVIEW_UPDATE_DELAY) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.solaredge_energy_this_year") assert state @@ -103,7 +103,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge().get_overview.return_value = mock_overview_data freezer.tick(OVERVIEW_UPDATE_DELAY) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.solaredge_lifetime_energy") assert state diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 0b3834992d89ae..576c9a807995de 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,16 +1,20 @@ """Configuration for Sonos tests.""" +import asyncio +from collections.abc import Callable from copy import copy from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from soco import SoCo +from soco.events_base import Event as SonosEvent from homeassistant.components import ssdp, zeroconf from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -30,6 +34,31 @@ def __init__(self, ip_address: str, *args, **kwargs) -> None: """Initialize the mock subscriber.""" self.event_listener = SonosMockEventListener(ip_address) self.service = Mock() + self.callback_future: asyncio.Future[Callable[[SonosEvent], None]] = None + self._callback: Callable[[SonosEvent], None] | None = None + + @property + def callback(self) -> Callable[[SonosEvent], None] | None: + """Return the callback.""" + return self._callback + + @callback.setter + def callback(self, callback: Callable[[SonosEvent], None]) -> None: + """Set the callback.""" + self._callback = callback + future = self._get_callback_future() + if not future.done(): + future.set_result(callback) + + def _get_callback_future(self) -> asyncio.Future[Callable[[SonosEvent], None]]: + """Get the callback future.""" + if not self.callback_future: + self.callback_future = asyncio.get_running_loop().create_future() + return self.callback_future + + async def wait_for_callback_to_be_set(self) -> Callable[[SonosEvent], None]: + """Wait for the callback to be set.""" + return await self._get_callback_future() async def unsubscribe(self) -> None: """Unsubscribe mock.""" @@ -94,8 +123,9 @@ def async_setup_sonos(hass, config_entry, fire_zgs_event): async def _wrapper(): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() + await hass.async_block_till_done(wait_background_tasks=True) return _wrapper @@ -455,14 +485,14 @@ def zgs_discovery_fixture(): @pytest.fixture(name="fire_zgs_event") -def zgs_event_fixture(hass, soco, zgs_discovery): +def zgs_event_fixture(hass: HomeAssistant, soco: SoCo, zgs_discovery: str): """Create alarm_event fixture.""" variables = {"ZoneGroupState": zgs_discovery} async def _wrapper(): event = SonosMockEvent(soco, soco.zoneGroupTopology, variables) - subscription = soco.zoneGroupTopology.subscribe.return_value - sub_callback = subscription.callback + subscription: SonosMockSubscribe = soco.zoneGroupTopology.subscribe.return_value + sub_callback = await subscription.wait_for_callback_to_be_set() sub_callback(event) await hass.async_block_till_done() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 77bf9a5d12b345..97fdc27d4617cd 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -51,7 +51,7 @@ async def test_creating_entry_sets_up_media_player( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index cc1f59c5cd0a9a..49b87b272d60b3 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -2,6 +2,8 @@ from unittest.mock import Mock +from soco import SoCo + from homeassistant.components.sonos.const import ( DOMAIN, SCAN_INTERVAL, @@ -11,27 +13,27 @@ from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry from homeassistant.util import dt as dt_util -from .conftest import SonosMockEvent +from .conftest import SonosMockEvent, SonosMockSubscribe from tests.common import MockConfigEntry, async_fire_time_changed async def test_subscription_repair_issues( - hass: HomeAssistant, config_entry: MockConfigEntry, soco, zgs_discovery + hass: HomeAssistant, config_entry: MockConfigEntry, soco: SoCo, zgs_discovery ) -> None: """Test repair issues handling for failed subscriptions.""" issue_registry = async_get_issue_registry(hass) - subscription = soco.zoneGroupTopology.subscribe.return_value + subscription: SonosMockSubscribe = soco.zoneGroupTopology.subscribe.return_value subscription.event_listener = Mock(address=("192.168.4.2", 1400)) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() # Ensure an issue is registered on subscription failure + sub_callback = await subscription.wait_for_callback_to_be_set() async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) # Ensure the issue still exists after reload @@ -42,7 +44,6 @@ async def test_subscription_repair_issues( # Ensure the issue has been removed after a successful subscription callback variables = {"ZoneGroupState": zgs_discovery} event = SonosMockEvent(soco, soco.zoneGroupTopology, variables) - sub_callback = subscription.callback sub_callback(event) await hass.async_block_till_done() assert not issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 6e4461e5397e9a..1f4ba8d22cdd3d 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -26,6 +26,7 @@ async def test_entity_registry_unsupported( soco.get_battery_info.side_effect = NotSupportedException await async_setup_sonos() + await hass.async_block_till_done(wait_background_tasks=True) assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities @@ -36,6 +37,8 @@ async def test_entity_registry_supported( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device with battery registered in the device registry.""" + await hass.async_block_till_done(wait_background_tasks=True) + assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_charging" in entity_registry.entities @@ -69,6 +72,7 @@ async def test_battery_on_s1( soco.get_battery_info.return_value = {} await async_setup_sonos() + await hass.async_block_till_done(wait_background_tasks=True) subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback @@ -78,7 +82,7 @@ async def test_battery_on_s1( # Update the speaker with a callback event sub_callback(device_properties_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) battery = entity_registry.entities["sensor.zone_a_battery"] battery_state = hass.states.get(battery.entity_id) @@ -101,6 +105,7 @@ async def test_device_payload_without_battery( soco.get_battery_info.return_value = None await async_setup_sonos() + await hass.async_block_till_done(wait_background_tasks=True) subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback @@ -109,7 +114,7 @@ async def test_device_payload_without_battery( device_properties_event.variables["more_info"] = bad_payload sub_callback(device_properties_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert bad_payload in caplog.text @@ -125,6 +130,7 @@ async def test_device_payload_without_battery_and_ignored_keys( soco.get_battery_info.return_value = None await async_setup_sonos() + await hass.async_block_till_done(wait_background_tasks=True) subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback @@ -133,7 +139,7 @@ async def test_device_payload_without_battery_and_ignored_keys( device_properties_event.variables["more_info"] = ignored_payload sub_callback(device_properties_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ignored_payload not in caplog.text @@ -150,7 +156,7 @@ async def test_audio_input_sensor( subscription = soco.avTransport.subscribe.return_value sub_callback = subscription.callback sub_callback(tv_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) audio_input_sensor = entity_registry.entities["sensor.zone_a_audio_input_format"] audio_input_state = hass.states.get(audio_input_sensor.entity_id) @@ -161,7 +167,7 @@ async def test_audio_input_sensor( type(soco).soundbar_audio_input_format = no_input_mock async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) no_input_mock.assert_called_once() audio_input_state = hass.states.get(audio_input_sensor.entity_id) @@ -169,13 +175,13 @@ async def test_audio_input_sensor( # Ensure state is not polled when source is not TV and state is already "No input" sub_callback(no_media_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) unpolled_mock = PropertyMock(return_value="Will not be polled") type(soco).soundbar_audio_input_format = unpolled_mock async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) unpolled_mock.assert_not_called() audio_input_state = hass.states.get(audio_input_sensor.entity_id) @@ -199,7 +205,7 @@ async def test_microphone_binary_sensor( # Update the speaker with a callback event subscription = soco.deviceProperties.subscribe.return_value subscription.callback(device_properties_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mic_binary_sensor_state = hass.states.get(mic_binary_sensor.entity_id) assert mic_binary_sensor_state.state == STATE_ON @@ -225,17 +231,18 @@ async def test_favorites_sensor( empty_event = SonosMockEvent(soco, service, {}) subscription = service.subscribe.return_value subscription.callback(event=empty_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Reload the integration to enable the sensor async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Trigger subscription callback for speaker discovery await fire_zgs_event() + await hass.async_block_till_done(wait_background_tasks=True) favorites_updated_event = SonosMockEvent( soco, service, {"favorites_update_id": "2", "container_update_i_ds": "FV:2,2"} @@ -245,4 +252,4 @@ async def test_favorites_sensor( return_value=True, ): subscription.callback(event=favorites_updated_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index e0fc4c3baf9793..2c4357060be01c 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -12,9 +12,20 @@ async def test_fallback_to_polling( - hass: HomeAssistant, async_autosetup_sonos, soco, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + config_entry, + soco, + fire_zgs_event, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that polling fallback works.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + # Do not wait on background tasks here because the + # subscription callback will fire an unsub the polling check + await hass.async_block_till_done() + await fire_zgs_event() + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] assert speaker.soco is soco assert speaker._subscriptions @@ -30,7 +41,7 @@ async def test_fallback_to_polling( ), ): async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert not speaker._subscriptions assert speaker.subscriptions_failed @@ -46,6 +57,7 @@ async def test_subscription_creation_fails( side_effect=ConnectionError("Took too long"), ): await async_setup_sonos() + await hass.async_block_till_done(wait_background_tasks=True) speaker = list(hass.data[DATA_SONOS].discovered.values())[0] assert not speaker._subscriptions diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index 94e6965a5715d8..61d0c7b4ea5261 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -665,7 +665,7 @@ async def test_zone_attributes( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) entity_1_state = hass.states.get(DEVICE_1_ENTITY_ID) assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 5083f56a8e2ab0..2b0f803eb6f19f 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -74,7 +74,7 @@ async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> Non hass, dt_util.utcnow() + timedelta(minutes=61), ) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("sensor.speedtest_ping") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/systemmonitor/test_binary_sensor.py b/tests/components/systemmonitor/test_binary_sensor.py index 51c8fc87a3af01..e3fbdedc081c12 100644 --- a/tests/components/systemmonitor/test_binary_sensor.py +++ b/tests/components/systemmonitor/test_binary_sensor.py @@ -97,7 +97,7 @@ async def test_sensor_process_fails( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) process_sensor = hass.states.get("binary_sensor.system_monitor_process_python3") assert process_sensor is not None diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 11dd002c2f70ee..a11112d8f86fb5 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -232,7 +232,7 @@ async def test_sensor_updating( mock_psutil.virtual_memory.side_effect = Exception("Failed to update") freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) memory_sensor = hass.states.get("sensor.system_monitor_memory_free") assert memory_sensor is not None @@ -248,7 +248,7 @@ async def test_sensor_updating( ) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) memory_sensor = hass.states.get("sensor.system_monitor_memory_free") assert memory_sensor is not None @@ -293,7 +293,7 @@ async def test_sensor_process_fails( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) process_sensor = hass.states.get("sensor.system_monitor_process_python3") assert process_sensor is not None @@ -330,7 +330,7 @@ async def test_sensor_network_sensors( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") @@ -362,7 +362,7 @@ async def test_sensor_network_sensors( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) network_out_sensor = hass.states.get("sensor.system_monitor_network_out_eth1") packets_out_sensor = hass.states.get("sensor.system_monitor_packets_out_eth1") @@ -470,7 +470,7 @@ async def test_exception_handling_disk_sensor( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "OS error for /" in caplog.text @@ -483,7 +483,7 @@ async def test_exception_handling_disk_sensor( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "OS error for /" in caplog.text @@ -498,7 +498,7 @@ async def test_exception_handling_disk_sensor( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) disk_sensor = hass.states.get("sensor.system_monitor_disk_free") assert disk_sensor is not None @@ -528,7 +528,7 @@ async def test_cpu_percentage_is_zero_returns_unknown( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) cpu_sensor = hass.states.get("sensor.system_monitor_processor_use") assert cpu_sensor is not None @@ -538,7 +538,7 @@ async def test_cpu_percentage_is_zero_returns_unknown( freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) cpu_sensor = hass.states.get("sensor.system_monitor_processor_use") assert cpu_sensor is not None @@ -573,7 +573,7 @@ async def test_remove_obsolete_entities( ) freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Fake an entity which should be removed as not supported and disabled entity_registry.async_get_or_create( diff --git a/tests/components/tcp/test_binary_sensor.py b/tests/components/tcp/test_binary_sensor.py index 959c1f050fd0f0..05aa2a471dba93 100644 --- a/tests/components/tcp/test_binary_sensor.py +++ b/tests/components/tcp/test_binary_sensor.py @@ -79,7 +79,7 @@ async def test_state(hass: HomeAssistant, mock_socket, now) -> None: mock_socket.recv.return_value = b"on" async_fire_time_changed(hass, now + timedelta(seconds=45)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(TEST_ENTITY) diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py index 94c44cc42966f7..d1e74f1ab0ffd0 100644 --- a/tests/components/temper/test_sensor.py +++ b/tests/components/temper/test_sensor.py @@ -29,7 +29,7 @@ async def test_temperature_readback(hass: HomeAssistant) -> None: await hass.async_block_till_done() async_fire_time_changed(hass, utcnow + timedelta(seconds=70)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) temperature = hass.states.get("sensor.mydevicename") assert temperature diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 30d6942750c1dc..0a34dff9776562 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -754,7 +754,7 @@ async def test_option_flow_preview( """Test the option flow preview.""" client = await hass_ws_client(hass) - input_entities = input_entities = ["one", "two"] + input_entities = ["one", "two"] # Setup the config entry config_entry = MockConfigEntry( diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 7ac6540f1ff427..fa2e997756d9bd 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -548,30 +548,30 @@ async def test_other_update_failures(hass: HomeAssistant) -> None: # then an error: ServiceUnavailable --> UpdateFailed async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 2 # works again async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 2) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 3 # then an error: TotalConnectError --> UpdateFailed async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 3) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 4 # works again async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 4) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 5 # unknown TotalConnect status via ValueError async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 5) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 6 diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index 3d112057315e6f..faf1b628fcd5a6 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -49,14 +49,14 @@ async def test_device_diagnostics( await setup_integration(hass, mock_config_entry) devices = dr.async_entries_for_config_entry( - hass.helpers.device_registry.async_get(hass), + device_registry, mock_config_entry.entry_id, ) assert len(devices) == 1 for device in dr.async_entries_for_config_entry( - hass.helpers.device_registry.async_get(hass), mock_config_entry.entry_id + device_registry, mock_config_entry.entry_id ): result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device=device @@ -78,14 +78,14 @@ async def test_device_diagnostics_with_disabled_entity( await setup_integration(hass, mock_config_entry) devices = dr.async_entries_for_config_entry( - hass.helpers.device_registry.async_get(hass), + device_registry, mock_config_entry.entry_id, ) assert len(devices) == 1 for device in dr.async_entries_for_config_entry( - hass.helpers.device_registry.async_get(hass), mock_config_entry.entry_id + device_registry, mock_config_entry.entry_id ): for entry in er.async_entries_for_device( entity_registry, diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index d5be861139b32f..8f9838e3e37bc5 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -1,20 +1,64 @@ """UniFi Network button platform tests.""" +from datetime import timedelta + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import CONF_SITE_ID +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_HOST, + CONTENT_TYPE_JSON, STATE_UNAVAILABLE, EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +import homeassistant.util.dt as dt_util from .test_hub import setup_unifi_integration +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +WLAN_ID = "_id" +WLAN = { + WLAN_ID: "012345678910111213141516", + "bc_filter_enabled": False, + "bc_filter_list": [], + "dtim_mode": "default", + "dtim_na": 1, + "dtim_ng": 1, + "enabled": True, + "group_rekey": 3600, + "mac_filter_enabled": False, + "mac_filter_list": [], + "mac_filter_policy": "allow", + "minrate_na_advertising_rates": False, + "minrate_na_beacon_rate_kbps": 6000, + "minrate_na_data_rate_kbps": 6000, + "minrate_na_enabled": False, + "minrate_na_mgmt_rate_kbps": 6000, + "minrate_ng_advertising_rates": False, + "minrate_ng_beacon_rate_kbps": 1000, + "minrate_ng_data_rate_kbps": 1000, + "minrate_ng_enabled": False, + "minrate_ng_mgmt_rate_kbps": 1000, + "name": "SSID 1", + "no2ghz_oui": False, + "schedule": [], + "security": "wpapsk", + "site_id": "5a32aa4ee4b0412345678910", + "usergroup_id": "012345678910111213141518", + "wep_idx": 1, + "wlangroup_id": "012345678910111213141519", + "wpa_enc": "ccmp", + "wpa_mode": "wpa2", + "x_iapp_key": "01234567891011121314151617181920", + "x_passphrase": "password", +} + async def test_restart_device_button( hass: HomeAssistant, @@ -168,3 +212,71 @@ async def test_power_cycle_poe( assert ( hass.states.get("button.switch_port_1_power_cycle").state != STATE_UNAVAILABLE ) + + +async def test_wlan_regenerate_password( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + websocket_mock, +) -> None: + """Test WLAN regenerate password button.""" + + config_entry = await setup_unifi_integration( + hass, aioclient_mock, wlans_response=[WLAN] + ) + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 0 + + button_regenerate_password = "button.ssid_1_regenerate_password" + + ent_reg_entry = entity_registry.async_get(button_regenerate_password) + assert ent_reg_entry.unique_id == "regenerate_password-012345678910111213141516" + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Enable entity + entity_registry.async_update_entity( + entity_id=button_regenerate_password, 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_entity_ids(BUTTON_DOMAIN)) == 1 + + # Validate state object + button = hass.states.get(button_regenerate_password) + assert button is not None + assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.UPDATE + + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{config_entry.data[CONF_HOST]}:1234" + f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/wlanconf/{WLAN[WLAN_ID]}", + json={"data": "password changed successfully", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + # Send WLAN regenerate password command + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {"entity_id": button_regenerate_password}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert next(iter(aioclient_mock.mock_calls[0][2])) == "x_passphrase" + + # Availability signalling + + # Controller disconnects + await websocket_mock.disconnect() + assert hass.states.get(button_regenerate_password).state == STATE_UNAVAILABLE + + # Controller reconnects + await websocket_mock.reconnect() + assert hass.states.get(button_regenerate_password).state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7a58252a6bdc1e..239707aa4c9947 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -998,3 +998,74 @@ async def test_device_state( device["state"] = i mock_unifi_websocket(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] + + +async def test_wlan_password( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + websocket_mock, +) -> None: + """Test the WLAN password sensor behavior.""" + await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) + + sensor_password = "sensor.ssid_1_password" + password = "password" + new_password = "new_password" + + ent_reg_entry = entity_registry.async_get(sensor_password) + assert ent_reg_entry.unique_id == "password-012345678910111213141516" + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + + # Enable entity + entity_registry.async_update_entity(entity_id=sensor_password, 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() + + # Validate state object + wlan_password_sensor_1 = hass.states.get(sensor_password) + assert wlan_password_sensor_1.state == password + + # Update state object - same password - no change to state + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=WLAN) + await hass.async_block_till_done() + wlan_password_sensor_2 = hass.states.get(sensor_password) + assert wlan_password_sensor_1.state == wlan_password_sensor_2.state + + # Update state object - changed password - new state + data = deepcopy(WLAN) + data["x_passphrase"] = new_password + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=data) + await hass.async_block_till_done() + wlan_password_sensor_3 = hass.states.get(sensor_password) + assert wlan_password_sensor_1.state != wlan_password_sensor_3.state + + # Availability signaling + + # Controller disconnects + await websocket_mock.disconnect() + assert hass.states.get(sensor_password).state == STATE_UNAVAILABLE + + # Controller reconnects + await websocket_mock.reconnect() + assert hass.states.get(sensor_password).state == new_password + + # WLAN gets disabled + wlan_1 = deepcopy(WLAN) + wlan_1["enabled"] = False + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get(sensor_password).state == STATE_UNAVAILABLE + + # WLAN gets re-enabled + wlan_1["enabled"] = True + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get(sensor_password).state == password diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 12203a3e2220d3..522448ecfc4007 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -278,7 +278,7 @@ async def test_setup_nvr_errors_during_indexing( mock_remote.return_value.index.side_effect = None async_fire_time_changed(hass, now + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) camera_states = hass.states.async_all("camera") @@ -313,7 +313,7 @@ async def test_setup_nvr_errors_during_initialization( mock_remote.side_effect = None async_fire_time_changed(hass, now + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) camera_states = hass.states.async_all("camera") @@ -362,7 +362,7 @@ async def test_motion_recording_mode_properties( ] = True async_fire_time_changed(hass, now + timedelta(seconds=31)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("camera.front") @@ -375,7 +375,7 @@ async def test_motion_recording_mode_properties( mock_remote.return_value.get_camera.return_value["recordingIndicator"] = "DISABLED" async_fire_time_changed(hass, now + timedelta(seconds=61)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("camera.front") @@ -387,7 +387,7 @@ async def test_motion_recording_mode_properties( ) async_fire_time_changed(hass, now + timedelta(seconds=91)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("camera.front") @@ -399,7 +399,7 @@ async def test_motion_recording_mode_properties( ) async_fire_time_changed(hass, now + timedelta(seconds=121)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("camera.front") diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 23c57177ddd338..94e1511ce19d2c 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -75,22 +75,21 @@ def call_api_side_effect__no_devices(*args, **kwargs): """Build a side_effects method for the Helpers.call_api method.""" if args[0] == "/cloud/v1/user/login" and args[1] == "post": return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": return ( json.loads( load_fixture("vesync_api_call__devices__no_devices.json", "vesync") ), 200, ) - else: - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") def call_api_side_effect__single_humidifier(*args, **kwargs): """Build a side_effects method for the Helpers.call_api method.""" if args[0] == "/cloud/v1/user/login" and args[1] == "post": return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": return ( json.loads( load_fixture( @@ -99,7 +98,7 @@ def call_api_side_effect__single_humidifier(*args, **kwargs): ), 200, ) - elif args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": + if args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": return ( json.loads( load_fixture( @@ -108,22 +107,21 @@ def call_api_side_effect__single_humidifier(*args, **kwargs): ), 200, ) - else: - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") def call_api_side_effect__single_fan(*args, **kwargs): """Build a side_effects method for the Helpers.call_api method.""" if args[0] == "/cloud/v1/user/login" and args[1] == "post": return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": return ( json.loads( load_fixture("vesync_api_call__devices__single_fan.json", "vesync") ), 200, ) - elif ( + if ( args[0] == "/131airPurifier/v1/device/deviceDetail" and kwargs["method"] == "post" ): @@ -135,5 +133,4 @@ def call_api_side_effect__single_fan(*args, **kwargs): ), 200, ) - else: - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/wemo/__init__.py b/tests/components/wemo/__init__.py index 33bdcacd37df0c..68d1516aed63b8 100644 --- a/tests/components/wemo/__init__.py +++ b/tests/components/wemo/__init__.py @@ -1 +1,5 @@ """Tests for the wemo component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.wemo.entity_test_helpers") diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index eec6bf191f77e7..c13f6cbd738b9b 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -195,7 +195,7 @@ async def test_update(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No with patch.object(MockWs66i, "open") as method_call: freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert not method_call.called @@ -226,13 +226,13 @@ async def test_failed_update( freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Failed update, close called with patch.object(MockWs66i, "zone_status", return_value=None): freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) @@ -240,12 +240,12 @@ async def test_failed_update( with patch.object(MockWs66i, "zone_status", return_value=None): freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # A connection re-attempt succeeds freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # confirm entity is back on state = hass.states.get(ZONE_1_ID) @@ -315,7 +315,7 @@ async def test_source_select( freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ZONE_1_ID) @@ -370,14 +370,14 @@ async def test_volume_up_down( ) freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # should not go below zero assert ws66i.zones[11].volume == 0 await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ws66i.zones[11].volume == 1 await _call_media_player_service( @@ -385,14 +385,14 @@ async def test_volume_up_down( ) freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) freezer.tick(POLL_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # should not go above 38 (MAX_VOL) assert ws66i.zones[11].volume == MAX_VOL diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 0a576b70bdf0fd..1b1d898add1bae 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -48,7 +48,7 @@ def raise_for_status(self): raise requests.HTTPError(self.status_code) data = kwargs.get("data") - global FIRST_CALL + global FIRST_CALL # noqa: PLW0603 if data and data.get("username", None) == INVALID_USERNAME: # deliver an invalid token diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index c53453867770d3..2cfc3a4f294086 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -238,7 +238,7 @@ def is_available(): mock_mirobo_is_on.status.side_effect = DeviceException("dummy exception") future = dt_util.utcnow() + timedelta(seconds=60) async_fire_time_changed(hass, future) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert not is_available() @@ -247,7 +247,7 @@ def is_available(): mock_mirobo_is_on.status.reset_mock() future += timedelta(seconds=60) async_fire_time_changed(hass, future) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert not is_available() assert mock_mirobo_is_on.status.call_count == 1 diff --git a/tests/components/yale_smart_alarm/test_coordinator.py b/tests/components/yale_smart_alarm/test_coordinator.py index 5125c817567382..6f1125fcf65775 100644 --- a/tests/components/yale_smart_alarm/test_coordinator.py +++ b/tests/components/yale_smart_alarm/test_coordinator.py @@ -76,7 +76,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.side_effect = ConnectionError("Could not connect") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_UNAVAILABLE @@ -84,7 +84,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.side_effect = ConnectionError("Could not connect") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_UNAVAILABLE @@ -92,7 +92,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.side_effect = TimeoutError("Could not connect") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_UNAVAILABLE @@ -100,7 +100,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.side_effect = UnknownError("info") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_UNAVAILABLE @@ -110,7 +110,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.return_value = load_json client.get_armed_status.return_value = YALE_STATE_ARM_FULL async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_ALARM_ARMED_AWAY @@ -118,7 +118,7 @@ async def test_coordinator_setup_and_update_errors( client.get_all.side_effect = AuthenticationError("Can not authenticate") async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) client.get_all.assert_called_once() state = hass.states.get("alarm_control_panel.yale_smart_alarm") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 63d3e9cf7479a5..6cda8b98e1e518 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -244,10 +244,7 @@ def patch_zha_config(component: str, overrides: dict[tuple[str, str], Any]): def new_get_config(config_entry, section, config_key, default): if (section, config_key) in overrides: return overrides[section, config_key] - else: - return async_get_zha_config_value( - config_entry, section, config_key, default - ) + return async_get_zha_config_value(config_entry, section, config_key, default) return patch( f"homeassistant.components.zha.{component}.async_get_zha_config_value", diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 9cd475a7bf8fac..623d7acf602868 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -303,7 +303,7 @@ async def test_update_zha_config( app_controller: ControllerApplication, ) -> None: """Test updating ZHA custom configuration.""" - configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration: dict = deepcopy(BASE_CUSTOM_CONFIGURATION) configuration["data"]["zha_options"]["default_light_transition"] = 10 with patch( @@ -318,8 +318,8 @@ async def test_update_zha_config( await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + test_configuration = msg["result"] + assert test_configuration == configuration await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/conftest.py b/tests/conftest.py index 157e0f2ba5955d..a38da17f44bd82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,6 +62,7 @@ label_registry as lr, recorder as recorder_helper, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from homeassistant.setup import BASE_PLATFORMS, async_setup_component from homeassistant.util import location @@ -985,7 +986,7 @@ async def _setup_mqtt_entry( # connected set to True to get a more realistic behavior when subscribing mock_mqtt_instance.connected = True - hass.helpers.dispatcher.async_dispatcher_send(mqtt.MQTT_CONNECTED) + async_dispatcher_send(hass, mqtt.MQTT_CONNECTED) await hass.async_block_till_done() return mock_mqtt_instance diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 1bc01c28cf2d70..d77eb698205671 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -15,6 +15,7 @@ config_validation as cv, device_registry as dr, entity_registry as er, + floor_registry as fr, intent, ) from homeassistant.setup import async_setup_component @@ -34,12 +35,25 @@ async def test_async_match_states( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test async_match_state helper.""" area_kitchen = area_registry.async_get_or_create("kitchen") - area_registry.async_update(area_kitchen.id, aliases={"food room"}) + area_kitchen = area_registry.async_update(area_kitchen.id, aliases={"food room"}) area_bedroom = area_registry.async_get_or_create("bedroom") + # Kitchen is on the first floor + floor_1 = floor_registry.async_create("first floor", aliases={"ground floor"}) + area_kitchen = area_registry.async_update( + area_kitchen.id, floor_id=floor_1.floor_id + ) + + # Bedroom is on the second floor + floor_2 = floor_registry.async_create("second floor") + area_bedroom = area_registry.async_update( + area_bedroom.id, floor_id=floor_2.floor_id + ) + state1 = State( "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) @@ -94,6 +108,13 @@ async def test_async_match_states( ) ) + # Invalid area + assert not list( + intent.async_match_states( + hass, area_name="invalid area", states=[state1, state2] + ) + ) + # Domain + area assert list( intent.async_match_states( @@ -111,6 +132,35 @@ async def test_async_match_states( ) ) == [state2] + # Floor + assert list( + intent.async_match_states( + hass, floor_name="first floor", states=[state1, state2] + ) + ) == [state1] + + assert list( + intent.async_match_states( + # Check alias + hass, + floor_name="ground floor", + states=[state1, state2], + ) + ) == [state1] + + assert list( + intent.async_match_states( + hass, floor_name="second floor", states=[state1, state2] + ) + ) == [state2] + + # Invalid floor + assert not list( + intent.async_match_states( + hass, floor_name="invalid floor", states=[state1, state2] + ) + ) + async def test_match_device_area( hass: HomeAssistant, @@ -300,3 +350,27 @@ async def mock_service(call): assert len(calls) == 1 assert calls[0].data == {"entity_id": "light.kitchen"} + + +async def test_invalid_area_floor_names(hass: HomeAssistant) -> None: + """Test that we throw an intent handle error with invalid area/floor names.""" + handler = intent.ServiceIntentHandler( + "TestType", "light", "turn_on", "Turned {} on" + ) + intent.async_register(hass, handler) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + "TestType", + slots={"area": {"value": "invalid area"}}, + ) + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + "TestType", + slots={"floor": {"value": "invalid floor"}}, + ) diff --git a/tests/ruff.toml b/tests/ruff.toml index 76e4feacdd2f5a..1a8876b9171468 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -2,22 +2,10 @@ extend = "../pyproject.toml" [lint] -extend-select = [ - "PT001", # Use @pytest.fixture without parentheses - "PT002", # Configuration for fixture specified via positional args, use kwargs - "PT003", # The scope='function' is implied in @pytest.fixture() - "PT006", # Single parameter in parameterize is a string, multiple a tuple - "PT013", # Found incorrect pytest import, use simple import pytest instead - "PT015", # Assertion always fails, replace with pytest.fail() - "PT021", # use yield instead of request.addfinalizer - "PT022", # No teardown in fixture, replace useless yield with return -] extend-ignore = [ "PLC", # pylint - "PLE", # pylint "PLR", # pylint - "PLW", # pylint "B904", # Use raise from to specify exception cause "N815", # Variable {name} in class scope should not be mixedCase ] diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py new file mode 100644 index 00000000000000..688852ecf55c2a --- /dev/null +++ b/tests/test_block_async_io.py @@ -0,0 +1,200 @@ +"""Tests for async util methods from Python source.""" + +import importlib +import time +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import block_async_io + +from tests.common import extract_stack_to_frame + + +async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> None: + """Test time.sleep injected by the debugger is not reported.""" + block_async_io.enable() + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/.venv/blah/pydevd.py", + lineno="23", + line="do_something()", + ), + ] + ) + with ( + patch( + "homeassistant.block_async_io.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + time.sleep(0) + assert "Detected blocking call inside the event loop" not in caplog.text + + +async def test_protect_loop_sleep(caplog: pytest.LogCaptureFixture) -> None: + """Test time.sleep not injected by the debugger raises.""" + block_async_io.enable() + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/no_dev.py", + lineno="23", + line="do_something()", + ), + ] + ) + with ( + pytest.raises( + RuntimeError, match="Detected blocking call to sleep inside the event loop" + ), + patch( + "homeassistant.block_async_io.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + time.sleep(0) + + +async def test_protect_loop_sleep_get_current_frame_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test time.sleep when get_current_frame raises ValueError.""" + block_async_io.enable() + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/no_dev.py", + lineno="23", + line="do_something()", + ), + ] + ) + with ( + pytest.raises( + RuntimeError, match="Detected blocking call to sleep inside the event loop" + ), + patch( + "homeassistant.block_async_io.get_current_frame", + side_effect=ValueError, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + time.sleep(0) + + +async def test_protect_loop_importlib_import_module_non_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test import_module in the loop for non-loaded module.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/no_dev.py", + lineno="23", + line="do_something()", + ), + ] + ) + with ( + pytest.raises(ImportError), + patch.object(block_async_io, "_IN_TESTS", False), + patch( + "homeassistant.block_async_io.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + block_async_io.enable() + importlib.import_module("not_loaded_module") + + assert "Detected blocking call to import_module" in caplog.text + + +async def test_protect_loop_importlib_import_loaded_module_non_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test import_module in the loop for a loaded module.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/no_dev.py", + lineno="23", + line="do_something()", + ), + ] + ) + with ( + patch.object(block_async_io, "_IN_TESTS", False), + patch( + "homeassistant.block_async_io.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + block_async_io.enable() + importlib.import_module("sys") + + assert "Detected blocking call to import_module" not in caplog.text + + +async def test_protect_loop_importlib_import_module_in_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test import_module in the loop for non-loaded module in an integration.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ] + ) + with ( + pytest.raises(ImportError), + patch.object(block_async_io, "_IN_TESTS", False), + patch( + "homeassistant.block_async_io.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + block_async_io.enable() + importlib.import_module("not_loaded_module") + + assert ( + "Detected blocking call to import_module inside the event loop by " + "integration 'hue' at homeassistant/components/hue/light.py, line 23" + ) in caplog.text diff --git a/tests/test_core.py b/tests/test_core.py index 11fda50a180a90..a0a197096cdcf1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -588,6 +588,46 @@ def run(self) -> None: my_job_create_task.join() +async def test_async_add_executor_job_background(hass: HomeAssistant) -> None: + """Test running an executor job in the background.""" + calls = [] + + def job(): + time.sleep(0.01) + calls.append(1) + + async def _async_add_executor_job(): + await hass.async_add_executor_job(job) + + task = hass.async_create_background_task( + _async_add_executor_job(), "background", eager_start=True + ) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.async_block_till_done(wait_background_tasks=True) + assert len(calls) == 1 + await task + + +async def test_async_add_executor_job(hass: HomeAssistant) -> None: + """Test running an executor job.""" + calls = [] + + def job(): + time.sleep(0.01) + calls.append(1) + + async def _async_add_executor_job(): + await hass.async_add_executor_job(job) + + task = hass.async_create_task( + _async_add_executor_job(), "background", eager_start=True + ) + await hass.async_block_till_done() + assert len(calls) == 1 + await task + + async def test_stage_shutdown(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index c8c6b21951d6b3..edba232eb693e3 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -269,8 +269,7 @@ async def async_finish_flow(self, flow, result): return flow.async_show_form( step_id="init", data_schema=vol.Schema({"count": int}) ) - else: - result["result"] = result["data"]["count"] + result["result"] = result["data"]["count"] return result manager = FlowManager(hass) diff --git a/tests/test_loader.py b/tests/test_loader.py index 9e191ee9e00dc7..4442fe5fd82306 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1779,3 +1779,34 @@ async def test_has_services(hass: HomeAssistant, enable_custom_integrations) -> assert integration.has_services is False integration = await loader.async_get_integration(hass, "test_with_services") assert integration.has_services is True + + +async def test_hass_helpers_use_reported( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: + """Test that use of hass.components is reported.""" + integration_frame = frame.IntegrationFrame( + custom_integration=True, + _frame=mock_integration_frame, + integration="test_integration_frame", + module="custom_components.test_integration_frame", + relative_filename="custom_components/test_integration_frame/__init__.py", + ) + + with ( + patch.object(frame, "_REPORTED_INTEGRATIONS", new=set()), + patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=integration_frame, + ), + patch( + "homeassistant.helpers.aiohttp_client.async_get_clientsession", + return_value=None, + ), + ): + hass.helpers.aiohttp_client.async_get_clientsession() + + assert ( + "Detected that custom integration 'test_integration_frame' " + "accesses hass.helpers.aiohttp_client. This is deprecated" + ) in caplog.text diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index eed98a8210acbb..4cd49fec606cb4 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -13,7 +13,7 @@ def init(empty=False): """Initialize the platform with entities.""" - global ENTITIES + global ENTITIES # noqa: PLW0603 ENTITIES = ( [] diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index ba5a91e2d24bc6..e97d3f8de224d9 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -12,7 +12,7 @@ def init(empty=False): """Initialize the platform with entities.""" - global ENTITIES + global ENTITIES # noqa: PLW0603 ENTITIES = ( {} diff --git a/tests/testing_config/custom_components/test/remote.py b/tests/testing_config/custom_components/test/remote.py index 541215f1c47a04..3226c93310c304 100644 --- a/tests/testing_config/custom_components/test/remote.py +++ b/tests/testing_config/custom_components/test/remote.py @@ -13,7 +13,7 @@ def init(empty=False): """Initialize the platform with entities.""" - global ENTITIES + global ENTITIES # noqa: PLW0603 ENTITIES = ( [] diff --git a/tests/testing_config/custom_components/test/select.py b/tests/testing_config/custom_components/test/select.py deleted file mode 100644 index fece370bdf1aef..00000000000000 --- a/tests/testing_config/custom_components/test/select.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Provide a mock select platform. - -Call init before using it in your tests to ensure clean test data. -""" - -from homeassistant.components.select import SelectEntity - -from tests.common import MockEntity - -UNIQUE_SELECT_1 = "unique_select_1" -UNIQUE_SELECT_2 = "unique_select_2" - -ENTITIES = [] - - -class MockSelectEntity(MockEntity, SelectEntity): - """Mock Select class.""" - - _attr_current_option = None - - @property - def current_option(self): - """Return the current option of this select.""" - return self._handle("current_option") - - @property - def options(self) -> list: - """Return the list of available options of this select.""" - return self._handle("options") - - def select_option(self, option: str) -> None: - """Change the selected option.""" - self._attr_current_option = option - - -def init(empty=False): - """Initialize the platform with entities.""" - global ENTITIES - - ENTITIES = ( - [] - if empty - else [ - MockSelectEntity( - name="select 1", - unique_id="unique_select_1", - options=["option 1", "option 2", "option 3"], - current_option="option 1", - ), - MockSelectEntity( - name="select 2", - unique_id="unique_select_2", - options=["option 1", "option 2", "option 3"], - ), - ] - ) - - -async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): - """Return mock entities.""" - async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 0e99ef48680964..b051531b9e8b2b 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -33,7 +33,7 @@ def init(empty=False): """Initialize the platform with entities.""" - global ENTITIES + global ENTITIES # noqa: PLW0603 ENTITIES = [] if empty else [MockWeather()] diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 50eecec72f6de3..d0131df88ee0f3 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -6,12 +6,9 @@ import pytest -from homeassistant import block_async_io from homeassistant.core import HomeAssistant from homeassistant.util import async_ as hasync -from tests.common import extract_stack_to_frame - @patch("concurrent.futures.Future") @patch("threading.get_ident") @@ -38,172 +35,6 @@ def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _) -> None: assert len(loop.call_soon_threadsafe.mock_calls) == 2 -def banned_function(): - """Mock banned function.""" - - -async def test_check_loop_async() -> None: - """Test check_loop detects when called from event loop without integration context.""" - with pytest.raises(RuntimeError): - hasync.check_loop(banned_function) - - -async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop detects and raises when called from event loop from integration context.""" - with ( - pytest.raises(RuntimeError), - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value="self.light.is_on", - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ), - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ] - ), - ), - ): - hasync.check_loop(banned_function) - assert ( - "Detected blocking call to banned_function inside the event loop by integration" - " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " - "please create a bug report at https://github.com/home-assistant/core/issues?" - "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text - ) - - -async def test_check_loop_async_integration_non_strict( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test check_loop detects when called from event loop from integration context.""" - with ( - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value="self.light.is_on", - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ), - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ] - ), - ), - ): - hasync.check_loop(banned_function, strict=False) - assert ( - "Detected blocking call to banned_function inside the event loop by integration" - " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " - "please create a bug report at https://github.com/home-assistant/core/issues?" - "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text - ) - - -async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop detects when called from event loop with custom component context.""" - with ( - pytest.raises(RuntimeError), - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value="self.light.is_on", - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - Mock( - filename="/home/paulus/config/custom_components/hue/light.py", - lineno="23", - line="self.light.is_on", - ), - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ] - ), - ), - ): - hasync.check_loop(banned_function) - assert ( - "Detected blocking call to banned_function inside the event loop by custom " - "integration 'hue' at custom_components/hue/light.py, line 23: self.light.is_on" - ", please create a bug report at https://github.com/home-assistant/core/issues?" - "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" - ) in caplog.text - - -def test_check_loop_sync(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop does nothing when called from thread.""" - hasync.check_loop(banned_function) - assert "Detected blocking call inside the event loop" not in caplog.text - - -def test_protect_loop_sync() -> None: - """Test protect_loop calls check_loop.""" - func = Mock() - with patch("homeassistant.util.async_.check_loop") as mock_check_loop: - hasync.protect_loop(func)(1, test=2) - mock_check_loop.assert_called_once_with(func, strict=True) - func.assert_called_once_with(1, test=2) - - -async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> None: - """Test time.sleep injected by the debugger is not reported.""" - block_async_io.enable() - - with patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/.venv/blah/pydevd.py", - lineno="23", - line="do_something()", - ), - ] - ), - ): - time.sleep(0) - assert "Detected blocking call inside the event loop" not in caplog.text - - async def test_gather_with_limited_concurrency() -> None: """Test gather_with_limited_concurrency limits the number of running tasks.""" diff --git a/tests/util/test_loop.py b/tests/util/test_loop.py new file mode 100644 index 00000000000000..8b4465bef2bea8 --- /dev/null +++ b/tests/util/test_loop.py @@ -0,0 +1,200 @@ +"""Tests for async util methods from Python source.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.util import loop as haloop + +from tests.common import extract_stack_to_frame + + +def banned_function(): + """Mock banned function.""" + + +async def test_check_loop_async() -> None: + """Test check_loop detects when called from event loop without integration context.""" + with pytest.raises(RuntimeError): + haloop.check_loop(banned_function) + + +async def test_check_loop_async_non_strict_core( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test non_strict_core check_loop detects from event loop without integration context.""" + haloop.check_loop(banned_function, strict_core=False) + assert "Detected blocking call to banned_function" in caplog.text + + +async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: + """Test check_loop detects and raises when called from event loop from integration context.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ] + ) + with ( + pytest.raises(RuntimeError), + patch( + "homeassistant.helpers.frame.linecache.getline", + return_value="self.light.is_on", + ), + patch( + "homeassistant.util.loop._get_line_from_cache", + return_value="mock_line", + ), + patch( + "homeassistant.util.loop.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + haloop.check_loop(banned_function) + assert ( + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on " + "(offender: /home/paulus/aiohue/lights.py, line 2: mock_line), please create " + "a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text + ) + + +async def test_check_loop_async_integration_non_strict( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test check_loop detects when called from event loop from integration context.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ] + ) + with ( + patch( + "homeassistant.helpers.frame.linecache.getline", + return_value="self.light.is_on", + ), + patch( + "homeassistant.util.loop._get_line_from_cache", + return_value="mock_line", + ), + patch( + "homeassistant.util.loop.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + haloop.check_loop(banned_function, strict=False) + assert ( + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on " + "(offender: /home/paulus/aiohue/lights.py, line 2: mock_line), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text + ) + + +async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: + """Test check_loop detects when called from event loop with custom component context.""" + frames = extract_stack_to_frame( + [ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/config/custom_components/hue/light.py", + lineno="23", + line="self.light.is_on", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ] + ) + with ( + pytest.raises(RuntimeError), + patch( + "homeassistant.helpers.frame.linecache.getline", + return_value="self.light.is_on", + ), + patch( + "homeassistant.util.loop._get_line_from_cache", + return_value="mock_line", + ), + patch( + "homeassistant.util.loop.get_current_frame", + return_value=frames, + ), + patch( + "homeassistant.helpers.frame.get_current_frame", + return_value=frames, + ), + ): + haloop.check_loop(banned_function) + assert ( + "Detected blocking call to banned_function inside the event loop by custom " + "integration 'hue' at custom_components/hue/light.py, line 23: self.light.is_on" + " (offender: /home/paulus/aiohue/lights.py, line 2: mock_line), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" + ) in caplog.text + + +def test_check_loop_sync(caplog: pytest.LogCaptureFixture) -> None: + """Test check_loop does nothing when called from thread.""" + haloop.check_loop(banned_function) + assert "Detected blocking call inside the event loop" not in caplog.text + + +def test_protect_loop_sync() -> None: + """Test protect_loop calls check_loop.""" + func = Mock() + with patch("homeassistant.util.loop.check_loop") as mock_check_loop: + haloop.protect_loop(func)(1, test=2) + mock_check_loop.assert_called_once_with( + func, + strict=True, + args=(1,), + check_allowed=None, + kwargs={"test": 2}, + strict_core=True, + ) + func.assert_called_once_with(1, test=2)