diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index fc5eedffc39..f733c6f9ff1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -414,6 +414,9 @@ async def async_from_config_dict( start = monotonic() hass.config_entries = config_entries.ConfigEntries(hass, config) + # Prime custom component cache early so we know if registry entries are tied + # to a custom integration + await loader.async_get_custom_components(hass) await async_load_base_functionality(hass) # Set up core. diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 152c56e80f3..f7f831df05c 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.3.3"] + "requirements": ["aranet4==2.3.4"] } diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 532fd859b4e..6168d483ab5 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -13,8 +13,8 @@ "crownstone_uart" ], "requirements": [ - "crownstone-cloud==1.4.9", - "crownstone-sse==2.0.4", + "crownstone-cloud==1.4.11", + "crownstone-sse==2.0.5", "crownstone-uart==2.1.0", "pyserial==3.5" ] diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f242416fa9a..864d6bfd9dc 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -50,9 +50,9 @@ def get_hardware_variant(config_entry: ConfigEntry) -> HardwareVariant: return HardwareVariant.from_usb_product_name(config_entry.data["product"]) -def get_zha_device_path(config_entry: ConfigEntry) -> str: +def get_zha_device_path(config_entry: ConfigEntry) -> str | None: """Get the device path from a ZHA config entry.""" - return cast(str, config_entry.data["device"]["path"]) + return cast(str | None, config_entry.data.get("device", {}).get("path", None)) @singleton(OTBR_ADDON_MANAGER_DATA) @@ -94,13 +94,15 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): zha_path = get_zha_device_path(zha_config_entry) - device_guesses[zha_path].append( - FirmwareGuess( - is_running=(zha_config_entry.state == ConfigEntryState.LOADED), - firmware_type=ApplicationType.EZSP, - source="zha", + + if zha_path is not None: + device_guesses[zha_path].append( + FirmwareGuess( + is_running=(zha_config_entry.state == ConfigEntryState.LOADED), + firmware_type=ApplicationType.EZSP, + source="zha", + ) ) - ) if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index cef5bc5030e..5c5f7fc8b8e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -54,7 +54,7 @@ def format_default(value: StateType) -> tuple[StateType, str | None]: if value is not None: # Clean up value and infer unit, e.g. -71dBm, 15 dB if match := re.match( - r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + r"((&[gl]t;|[><])=?)?(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) ): try: value = float(match.group("value")) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index da72798dda1..acd85884875 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -52,7 +52,10 @@ DEFAULT_TRANSITION = 0.2 # sw version (attributeKey 0/40/10) TRANSITION_BLOCKLIST = ( (4488, 514, "1.0", "1.0.0"), + (4488, 260, "1.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"), + (4999, 25057, "1.0", "27.0"), + (4448, 36866, "V1", "V1.0.0.5"), ) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 8245363fd85..0261512fe99 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -542,6 +542,14 @@ class MQTT: def _increase_socket_buffer_size(self, sock: SocketType) -> None: """Increase the socket buffer size.""" + if not hasattr(sock, "setsockopt") and hasattr(sock, "_socket"): + # The WebsocketWrapper does not wrap setsockopt + # so we need to get the underlying socket + # Remove this once + # https://github.com/eclipse/paho.mqtt.python/pull/843 + # is available. + sock = sock._socket # pylint: disable=protected-access + new_buffer_size = PREFERRED_BUFFER_SIZE while True: try: diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 4751e85d378..b4ca9b931a7 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.1.1"] + "requirements": ["ha-philipsjs==3.2.1"] } diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 10718a818ff..a3e2a567e85 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -91,13 +91,17 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): super().__init__(coordinator, device_id) self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - self._attr_options = self.device[entity_description.options_key] @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] + @property + def options(self) -> list[str]: + """Return the available select-options.""" + return self.device[self.entity_description.options_key] + async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" await self.entity_description.command( diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 22e73a10d6d..25520d1f96e 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.6.1"] + "requirements": ["pyrisco==0.6.2"] } diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index e14ac9af71f..046e778f05b 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.7"] + "requirements": ["pyrympro==0.0.8"] } diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 46cea4e49a4..ccc86c564d5 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -256,6 +256,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if ( current_entry := await self.async_set_unique_id(mac) ) and current_entry.data.get(CONF_HOST) == host: + LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac) await async_reconnect_soon(self.hass, current_entry) if host == INTERNAL_WIFI_AP_IP: # If the device is broadcasting the internal wifi ap ip diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index eeadd7db232..008c539581b 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -53,14 +53,16 @@ def get_thumbnail_url_full( media_content_type: str, media_content_id: str, media_image_id: str | None = None, + item: MusicServiceItem | None = None, ) -> str | None: """Get thumbnail URL.""" if is_internal: - item = get_media( - media.library, - media_content_id, - media_content_type, - ) + if not item: + item = get_media( + media.library, + media_content_id, + media_content_type, + ) return urllib.parse.unquote(getattr(item, "album_art_uri", "")) return urllib.parse.unquote( @@ -255,7 +257,7 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia: content_id = get_content_id(item) thumbnail = None if getattr(item, "album_art_uri", None): - thumbnail = get_thumbnail_url(media_class, content_id) + thumbnail = get_thumbnail_url(media_class, content_id, item=item) return BrowseMedia( title=item.title, diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 06b95c6f8aa..bb69da52239 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -8,6 +8,7 @@ from typing import Any from switchbot import ( SwitchbotAccountConnectionError, SwitchBotAdvertisement, + SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotLock, SwitchbotModel, @@ -33,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ENCRYPTION_KEY, @@ -175,14 +177,19 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders = {} if user_input is not None: try: - key_details = await self.hass.async_add_executor_job( - SwitchbotLock.retrieve_encryption_key, + key_details = await SwitchbotLock.async_retrieve_encryption_key( + async_get_clientsession(self.hass), self._discovered_adv.address, user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) - except SwitchbotAccountConnectionError as ex: - raise AbortFlow("cannot_connect") from ex + except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex: + _LOGGER.debug( + "Failed to connect to SwitchBot API: %s", ex, exc_info=True + ) + raise AbortFlow( + "api_error", description_placeholders={"error_detail": str(ex)} + ) from ex except SwitchbotAuthenticationError as ex: _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 401d85e7376..2388e5a98b3 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.45.0"] + "requirements": ["PySwitchbot==0.46.1"] } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 8eab1ec6f1a..a20b4939f8f 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -46,7 +46,7 @@ "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "api_error": "Error while communicating with SwitchBot API: {error_detail}", "switchbot_unsupported_type": "Unsupported Switchbot Type." } }, diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 4699a1a5c20..4b0c19b2b55 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -27,7 +27,9 @@ from .models import SynologyDSMData async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Synology media source.""" - entries = hass.config_entries.async_entries(DOMAIN) + entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) hass.http.register_view(SynologyDsmMediaView(hass)) return SynologyPhotosMediaSource(hass, entries) diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index ed1878caecb..e8f73f22d20 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -37,7 +37,7 @@ "not_connected": "Vehicle not connected", "connected": "Vehicle connected", "ready": "Ready to charge", - "negociating": "Negociating connection", + "negotiating": "Negotiating connection", "error": "Error", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index fd15d8ef171..a01bbcabdd6 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.17.1"], + "requirements": ["wled==0.18.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f982f63b948..252f7be8b7e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -514,6 +514,15 @@ class ConfigEntry: # Only store setup result as state if it was not forwarded. if domain_is_integration := self.domain == integration.domain: + if self.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be setup because is already loaded in the" + f" {self.state} state" + ) self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: @@ -709,6 +718,17 @@ class ConfigEntry: ) -> None: """Set up while holding the setup lock.""" async with self.setup_lock: + if self.state is ConfigEntryState.LOADED: + # If something loaded the config entry while + # we were waiting for the lock, we should not + # set it up again. + _LOGGER.debug( + "Not setting up %s (%s %s) again, already loaded", + self.title, + self.domain, + self.entry_id, + ) + return await self.async_setup(hass, integration=integration) @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index 278050b69e1..e0832f7cc85 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5c026064c28..67b057463dd 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1766,7 +1766,6 @@ class _TrackUTCTimeChange: # time when the timer was scheduled utc_now = time_tracker_utcnow() localized_now = dt_util.as_local(utc_now) if self.local else utc_now - hass.async_run_hass_job(self.job, localized_now, background=True) if TYPE_CHECKING: assert self._pattern_time_change_listener_job is not None self._cancel_callback = async_track_point_in_utc_time( @@ -1774,6 +1773,7 @@ class _TrackUTCTimeChange: self._pattern_time_change_listener_job, self._calculate_next(utc_now + timedelta(seconds=1)), ) + hass.async_run_hass_job(self.job, localized_now, background=True) @callback def async_cancel(self) -> None: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 89c3442be6a..d8b32b053db 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -96,6 +96,11 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { "dreame_vacuum": BlockedIntegration( AwesomeVersion("1.0.4"), "crashes Home Assistant" ), + # Added in 2024.5.5 because of + # https://github.com/sh00t2kill/dolphin-robot/issues/185 + "mydolphin_plus": BlockedIntegration( + AwesomeVersion("1.0.13"), "crashes Home Assistant" + ), } DATA_COMPONENTS = "components" @@ -1669,6 +1674,14 @@ def async_get_issue_tracker( # If we know nothing about the entity, suggest opening an issue on HA core return issue_tracker + if ( + not integration + and (hass and integration_domain) + and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) + and not isinstance(comps_or_future, asyncio.Future) + ): + integration = comps_or_future.get(integration_domain) + if not integration and (hass and integration_domain): with suppress(IntegrationNotLoaded): integration = async_get_loaded_integration(hass, integration_domain) diff --git a/pyproject.toml b/pyproject.toml index 1805545235f..b84159eb457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.4" +version = "2024.5.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" diff --git a/requirements_all.txt b/requirements_all.txt index 675b01a31b9..bd747808819 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -93,7 +93,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.46.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -461,7 +461,7 @@ aprslib==0.7.2 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 @@ -670,10 +670,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -1029,7 +1029,7 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.1 # homeassistant.components.habitica habitipy==0.2.0 @@ -2093,7 +2093,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.2 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2102,7 +2102,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 @@ -2866,7 +2866,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.17.1 +wled==0.18.0 # homeassistant.components.wolflink wolf-comm==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c313ef952a3..716abc3edd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.46.1 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -422,7 +422,7 @@ apprise==1.7.4 aprslib==0.7.2 # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 @@ -554,10 +554,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -843,7 +843,7 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.1 # homeassistant.components.habitica habitipy==0.2.0 @@ -1635,7 +1635,7 @@ pyqwikswitch==0.93 pyrainbird==4.0.2 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.2 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1644,7 +1644,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 @@ -2222,7 +2222,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.17.1 +wled==0.18.0 # homeassistant.components.wolflink wolf-comm==0.0.7 diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 12ba352eb16..b560acc65b7 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -94,6 +94,18 @@ def test_get_zha_device_path() -> None: ) +def test_get_zha_device_path_ignored_discovery() -> None: + """Test extracting the ZHA device path from an ignored ZHA discovery.""" + config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={}, + version=4, + ) + + assert get_zha_device_path(config_entry) is None + + async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" diff --git a/tests/components/huawei_lte/test_sensor.py b/tests/components/huawei_lte/test_sensor.py index 4d5acaf2d31..75cdc7be1c2 100644 --- a/tests/components/huawei_lte/test_sensor.py +++ b/tests/components/huawei_lte/test_sensor.py @@ -15,6 +15,8 @@ from homeassistant.const import ( ("-71 dBm", (-71, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), ("15dB", (15, SIGNAL_STRENGTH_DECIBELS)), (">=-51dBm", (-51, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), + ("<-20dB", (-20, SIGNAL_STRENGTH_DECIBELS)), + (">=30dB", (30, SIGNAL_STRENGTH_DECIBELS)), ], ) def test_format_default(value, expected) -> None: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 448d41c59cc..6ead70e4150 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -4410,6 +4410,43 @@ async def test_server_sock_buffer_size( assert "Unable to increase the socket buffer size" in caplog.text +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_server_sock_buffer_size_with_websocket( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling the socket buffer size fails.""" + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + assert mqtt_mock.connected is True + + mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS + + client, server = socket.socketpair( + family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0 + ) + client.setblocking(False) + server.setblocking(False) + + class FakeWebsocket(paho_mqtt.WebsocketWrapper): + def _do_handshake(self, *args, **kwargs): + pass + + wrapped_socket = FakeWebsocket(client, "127.0.01", 1, False, "/", None) + + with patch.object(client, "setsockopt", side_effect=OSError("foo")): + mqtt_client_mock.on_socket_open(mqtt_client_mock, None, wrapped_socket) + mqtt_client_mock.on_socket_register_write( + mqtt_client_mock, None, wrapped_socket + ) + await hass.async_block_till_done() + assert "Unable to increase the socket buffer size" in caplog.text + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 15f371f272c..a7062b24e88 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -316,12 +316,35 @@ def sonos_favorites_fixture() -> SearchResult: class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" - def __init__(self, title: str, item_id: str, parent_id: str, item_class: str): + def __init__( + self, + title: str, + item_id: str, + parent_id: str, + item_class: str, + album_art_uri: None | str = None, + ): """Initialize the mock item.""" self.title = title self.item_id = item_id self.item_class = item_class self.parent_id = parent_id + self.album_art_uri: None | str = album_art_uri + + +def list_from_json_fixture(file_name: str) -> list[MockMusicServiceItem]: + """Create a list of music service items from a json fixture file.""" + item_list = load_json_value_fixture(file_name, "sonos") + return [ + MockMusicServiceItem( + item.get("title"), + item.get("item_id"), + item.get("parent_id"), + item.get("item_class"), + item.get("album_art_uri"), + ) + for item in item_list + ] def mock_browse_by_idstring( @@ -398,6 +421,10 @@ def mock_browse_by_idstring( "object.container.album.musicAlbum", ), ] + if search_type == "tracks": + return list_from_json_fixture("music_library_tracks.json") + if search_type == "albums" and idstring == "A:ALBUM": + return list_from_json_fixture("music_library_albums.json") return [] @@ -416,13 +443,23 @@ def mock_get_music_library_information( ] +@pytest.fixture(name="music_library_browse_categories") +def music_library_browse_categories() -> list[MockMusicServiceItem]: + """Create fixture for top-level music library categories.""" + return list_from_json_fixture("music_library_categories.json") + + @pytest.fixture(name="music_library") -def music_library_fixture(sonos_favorites: SearchResult) -> Mock: +def music_library_fixture( + sonos_favorites: SearchResult, + music_library_browse_categories: list[MockMusicServiceItem], +) -> Mock: """Create music_library fixture.""" music_library = MagicMock() music_library.get_sonos_favorites.return_value = sonos_favorites - music_library.browse_by_idstring = mock_browse_by_idstring + music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring) music_library.get_music_library_information = mock_get_music_library_information + music_library.browse = Mock(return_value=music_library_browse_categories) return music_library diff --git a/tests/components/sonos/fixtures/music_library_albums.json b/tests/components/sonos/fixtures/music_library_albums.json new file mode 100644 index 00000000000..4941abe8ba7 --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_albums.json @@ -0,0 +1,23 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "A:ALBUM/A%20Hard%20Day's%20Night", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fA%2520Hard%2520Day's%2520Night%2f01%2520A%2520Hard%2520Day's%2520Night%25201.m4a&v=53" + }, + { + "title": "Abbey Road", + "item_id": "A:ALBUM/Abbey%20Road", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fAbbeyA%2520Road%2f01%2520Come%2520Together.m4a&v=53" + }, + { + "title": "Between Good And Evil", + "item_id": "A:ALBUM/Between%20Good%20And%20Evil", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fSantana%2fA%2520Between%2520Good%2520And%2520Evil%2f02%2520A%2520Persuasion.m4a&v=53" + } +] diff --git a/tests/components/sonos/fixtures/music_library_categories.json b/tests/components/sonos/fixtures/music_library_categories.json new file mode 100644 index 00000000000..b6d6d3bf2dd --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_categories.json @@ -0,0 +1,44 @@ +[ + { + "title": "Contributing Artists", + "item_id": "A:ARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Artists", + "item_id": "A:ALBUMARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Albums", + "item_id": "A:ALBUM", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Genres", + "item_id": "A:GENRE", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Composers", + "item_id": "A:COMPOSER", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Tracks", + "item_id": "A:TRACKS", + "parent_id": "A:", + "item_class": "object.container.playlistContainer" + }, + { + "title": "Playlists", + "item_id": "A:PLAYLISTS", + "parent_id": "A:", + "item_class": "object.container" + } +] diff --git a/tests/components/sonos/fixtures/music_library_tracks.json b/tests/components/sonos/fixtures/music_library_tracks.json new file mode 100644 index 00000000000..1f1fcdbc21c --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_tracks.json @@ -0,0 +1,14 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%20Night/A%20Hard%20Day%2fs%20Night.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + }, + { + "title": "I Should Have Known Better", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + } +] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..b4388b148e5 --- /dev/null +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_browse_media_library + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'contributing_artist', + 'media_content_id': 'A:ARTIST', + 'media_content_type': 'contributing_artist', + 'thumbnail': None, + 'title': 'Contributing Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'artist', + 'media_content_id': 'A:ALBUMARTIST', + 'media_content_type': 'artist', + 'thumbnail': None, + 'title': 'Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM', + 'media_content_type': 'album', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'A:GENRE', + 'media_content_type': 'genre', + 'thumbnail': None, + 'title': 'Genres', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'composer', + 'media_content_id': 'A:COMPOSER', + 'media_content_type': 'composer', + 'thumbnail': None, + 'title': 'Composers', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'A:TRACKS', + 'media_content_type': 'track', + 'thumbnail': None, + 'title': 'Tracks', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'playlist', + 'media_content_id': 'A:PLAYLISTS', + 'media_content_type': 'playlist', + 'thumbnail': None, + 'title': 'Playlists', + }), + ]) +# --- +# name: test_browse_media_library_albums + list([ + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night", + 'media_content_type': 'album', + 'thumbnail': "http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/A%20Hard%20Day's%20Night/01%20A%20Hard%20Day's%20Night%201.m4a&v=53", + 'title': "A Hard Day's Night", + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Abbey%20Road', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/AbbeyA%20Road/01%20Come%20Together.m4a&v=53', + 'title': 'Abbey Road', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/Santana/A%20Between%20Good%20And%20Evil/02%20A%20Persuasion.m4a&v=53', + 'title': 'Between Good And Evil', + }), + ]) +# --- +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Favorites', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'library', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Music Library', + }), + ]) +# --- diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index d8d0e1c3a07..4f6c2f53d8b 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,6 +2,8 @@ from functools import partial +from syrupy import SnapshotAssertion + from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.components.media_player.const import MediaClass, MediaType from homeassistant.components.sonos.media_browser import ( @@ -12,6 +14,8 @@ from homeassistant.core import HomeAssistant from .conftest import SoCoMockFactory +from tests.typing import WebSocketGenerator + class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" @@ -95,3 +99,81 @@ async def test_build_item_response( browse_item.children[1].media_content_id == "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3" ) + + +async def test_browse_media_root( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "", + "media_content_type": "library", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library_albums( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "A:ALBUM", + "media_content_type": "album", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + assert soco_mock.music_library.browse_by_idstring.call_count == 1 diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index a62a100f55a..182e9457f22 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -487,7 +487,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", side_effect=SwitchbotAuthenticationError("error from api"), ): result = await hass.config_entries.flow.async_configure( @@ -510,7 +510,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: return_value=True, ), patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", return_value={ CONF_KEY_ID: "ff", CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", @@ -560,8 +560,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", - side_effect=SwitchbotAccountConnectionError, + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + side_effect=SwitchbotAccountConnectionError("Switchbot API down"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -572,7 +572,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index a3d2b97f3ed..a4598346a51 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -196,7 +196,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_mac(hass: HomeAssistant) -> CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -228,7 +228,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_host(hass: HomeAssistant) - CONFIG_ENTRY_HOST: TEST_HOST, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -266,7 +266,7 @@ async def test_flow_ssdp_discovery_changed_udn_but_st_differs( CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -320,7 +320,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a6fad968eac..7fb02024170 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4589,6 +4589,40 @@ async def test_async_track_point_in_time_cancel(hass: HomeAssistant) -> None: assert "US/Hawaii" in str(times[0].tzinfo) +async def test_async_track_point_in_time_cancel_in_job( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test cancel of async track point in time during job execution.""" + + now = dt_util.utcnow() + times = [] + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC + ) + freezer.move_to(time_that_will_not_match_right_away) + + @callback + def action(x: datetime): + nonlocal times + times.append(x) + unsub() + + unsub = async_track_utc_time_change(hass, action, minute=0, second="*") + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 13, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> None: """Test tracking entity registry updates for an entity_id.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8d7efad8918..1394ca1e435 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -90,6 +90,89 @@ async def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: return manager +async def test_setup_race_only_setup_once(hass: HomeAssistant) -> None: + """Test ensure that config entries are only setup once.""" + attempts = 0 + slow_config_entry_setup_future = hass.loop.create_future() + fast_config_entry_setup_future = hass.loop.create_future() + slow_setup_future = hass.loop.create_future() + + async def async_setup(hass, config): + """Mock setup.""" + await slow_setup_future + return True + + async def async_setup_entry(hass, entry): + """Mock setup entry.""" + slow = entry.data["slow"] + if slow: + await slow_config_entry_setup_future + return True + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ConfigEntryNotReady + await fast_config_entry_setup_future + return True + + async def async_unload_entry(hass, entry): + """Mock unload entry.""" + return True + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + entry = MockConfigEntry(domain="comp", data={"slow": False}) + entry.add_to_hass(hass) + + entry2 = MockConfigEntry(domain="comp", data={"slow": True}) + entry2.add_to_hass(hass) + await entry2.setup_lock.acquire() + + async def _async_reload_entry(entry: MockConfigEntry): + async with entry.setup_lock: + await entry.async_unload(hass) + await entry.async_setup(hass) + + hass.async_create_task(_async_reload_entry(entry2)) + + setup_task = hass.async_create_task(async_setup_component(hass, "comp", {})) + entry2.setup_lock.release() + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert entry2.state is config_entries.ConfigEntryState.NOT_LOADED + + assert "comp" not in hass.config.components + slow_setup_future.set_result(None) + await asyncio.sleep(0) + assert "comp" in hass.config.components + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert entry2.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + fast_config_entry_setup_future.set_result(None) + # Make sure setup retry is started + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + slow_config_entry_setup_future.set_result(None) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + assert attempts == 2 + await hass.async_block_till_done() + assert setup_task.done() + assert entry2.state is config_entries.ConfigEntryState.LOADED + + async def test_call_setup_entry(hass: HomeAssistant) -> None: """Test we call .setup_entry.""" entry = MockConfigEntry(domain="comp") @@ -386,7 +469,7 @@ async def test_remove_entry( ] # Setup entry - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() # Check entity state got added @@ -1613,7 +1696,9 @@ async def test_entry_reload_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can reload an entry.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) @@ -1637,6 +1722,42 @@ async def test_entry_reload_succeed( assert entry.state is config_entries.ConfigEntryState.LOADED +@pytest.mark.parametrize( + "state", + [ + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.SETUP_IN_PROGRESS, + ], +) +async def test_entry_cannot_be_loaded_twice( + hass: HomeAssistant, state: config_entries.ConfigEntryState +) -> None: + """Test that a config entry cannot be loaded twice.""" + entry = MockConfigEntry(domain="comp", state=state) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): + await entry.async_setup(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is state + + @pytest.mark.parametrize( "state", [ @@ -4005,7 +4126,9 @@ async def test_entry_reload_concurrency_not_setup_setup( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test multiple reload calls do not cause a reload race.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) diff --git a/tests/test_loader.py b/tests/test_loader.py index 404858200bc..09afdf1504b 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2,6 +2,7 @@ import asyncio import os +import pathlib import sys import threading from typing import Any @@ -1108,14 +1109,18 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" # Integration domain is not currently deduced from module (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), - # Custom integration with known issue tracker + # Loaded custom integration with known issue tracker ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), - # Custom integration without known issue tracker + # Loaded custom integration without known issue tracker (None, "custom_components.bla.sensor", None), ("bla_custom_no_tracker", "custom_components.bla_custom.sensor", None), ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), + # Unloaded custom integration with known issue tracker + ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), + # Unloaded custom integration without known issue tracker + ("bla_custom_not_loaded_no_tracker", None, None), # Integration domain has priority over module ("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None), ], @@ -1133,6 +1138,32 @@ async def test_async_get_issue_tracker( built_in=False, ) mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + + cust_unloaded_module = MockModule( + "bla_custom_not_loaded", + partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER}, + ) + cust_unloaded = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_module.mock_manifest(), + set(), + ) + + cust_unloaded_no_tracker_module = MockModule("bla_custom_not_loaded_no_tracker") + cust_unloaded_no_tracker = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_no_tracker_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_no_tracker_module.mock_manifest(), + set(), + ) + hass.data["custom_components"] = { + "bla_custom_not_loaded": cust_unloaded, + "bla_custom_not_loaded_no_tracker": cust_unloaded_no_tracker, + } + assert ( loader.async_get_issue_tracker(hass, integration_domain=domain, module=module) == issue_tracker