diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 9ea0d2a4c20d..60fbd0dda320 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -26,7 +26,7 @@ PLATFORMS = [ def _call_api(data: dict[str, Any]) -> Api: """Call the Mikrotik API.""" - with mikrotik_config_entry_errors(): + with mikrotik_config_entry_errors(during_setup=True): api: Api = get_api(data) return api diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index ad7cbb9eb90c..347f4d655224 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -78,11 +78,18 @@ class MikrotikData: self.sensors: dict[str, Any] = {} self.system: dict[str, Any] = {} - def _get_system_details(self) -> None: + def _get_system_details(self, during_setup: bool = False) -> None: """Retrieve system and routerboard details from Mikrotik API.""" - self.system[IDENTITY] = (self.command(MIKROTIK_SERVICES[IDENTITY]) or [{}])[0] + self.system[IDENTITY] = ( + self.command(MIKROTIK_SERVICES[IDENTITY], during_setup=during_setup) or [{}] + )[0] self.system[ROUTERBOARD] = ( - self.command(MIKROTIK_SERVICES[ROUTERBOARD], suppress_errors=True) or [{}] + self.command( + MIKROTIK_SERVICES[ROUTERBOARD], + suppress_errors=True, + during_setup=during_setup, + ) + or [{}] )[0] @staticmethod @@ -107,22 +114,32 @@ class MikrotikData: def get_hub_details(self) -> None: """Get Hub info.""" - self._get_system_details() + self._get_system_details(during_setup=True) self.hostname = str(self.system[IDENTITY].get(NAME)) self.model = str(self.system[ROUTERBOARD].get(ATTR_MODEL)) self.firmware = str(self.system[ROUTERBOARD].get(ATTR_ROUTERBOARD_FIRMWARE)) self.serial_number = str(self.system[ROUTERBOARD].get(ATTR_SERIAL_NUMBER)) self.support_capsman = bool( - self.command(MIKROTIK_SERVICES[IS_CAPSMAN], suppress_errors=True) + self.command( + MIKROTIK_SERVICES[IS_CAPSMAN], suppress_errors=True, during_setup=True + ) ) self.support_wireless = bool( - self.command(MIKROTIK_SERVICES[IS_WIRELESS], suppress_errors=True) + self.command( + MIKROTIK_SERVICES[IS_WIRELESS], suppress_errors=True, during_setup=True + ) ) self.support_wifiwave2 = bool( - self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2], suppress_errors=True) + self.command( + MIKROTIK_SERVICES[IS_WIFIWAVE2], + suppress_errors=True, + during_setup=True, + ) ) self.support_wifi = bool( - self.command(MIKROTIK_SERVICES[IS_WIFI], suppress_errors=True) + self.command( + MIKROTIK_SERVICES[IS_WIFI], suppress_errors=True, during_setup=True + ) ) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: @@ -238,10 +255,13 @@ class MikrotikData: cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False, + during_setup: bool = False, ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) - with mikrotik_config_entry_errors(suppress_errors=suppress_errors): + with mikrotik_config_entry_errors( + suppress_errors=suppress_errors, during_setup=during_setup + ): if params: return list(self.api(cmd, **params)) return list(self.api(cmd)) diff --git a/homeassistant/components/mikrotik/utils.py b/homeassistant/components/mikrotik/utils.py index 4bc50d05f7c1..9299fc6ddc58 100644 --- a/homeassistant/components/mikrotik/utils.py +++ b/homeassistant/components/mikrotik/utils.py @@ -10,14 +10,24 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers.update_coordinator import UpdateFailed from .const import DOMAIN from .errors import CannotConnect, LoginError @contextmanager -def mikrotik_config_entry_errors(suppress_errors: bool = False) -> Generator[None]: - """Handle common Mikrotik API exceptions as ConfigEntry errors.""" +def mikrotik_config_entry_errors( + suppress_errors: bool = False, during_setup: bool = False +) -> Generator[None]: + """Handle common Mikrotik API exceptions as ConfigEntry errors. + + `during_setup`: + - True when called from `async_setup_entry` so connectivity errors raise + `ConfigEntryNotReady`. + - False when called from the coordinator's update cycle, so connectivity errors + raise `UpdateFailed` instead. + """ try: yield except LoginError as err: @@ -26,7 +36,13 @@ def mikrotik_config_entry_errors(suppress_errors: bool = False) -> Generator[Non translation_key="invalid_auth", ) from err except (CannotConnect, OSError, TimeoutError, ConnectionClosed) as err: - raise ConfigEntryNotReady( + if during_setup: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", translation_placeholders={"error": repr(err)}, diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 5947a5742b18..75a38eaaf25a 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -70,6 +70,7 @@ async def setup_integration( cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False, + during_setup: bool = False, ) -> Any: return command_responses.get(cmd, {}) diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 760b822129ea..29c98ea45197 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -55,7 +55,11 @@ def mock_device_registry_devices( def mock_command( - self, cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, + during_setup: bool = False, ) -> Any: """Mock the Mikrotik command method.""" if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index 52d2f1b07d5f..baae28984058 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,14 +1,19 @@ """Test Mikrotik setup process.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import MagicMock, patch from librouteros.exceptions import ConnectionClosed, LibRouterosError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util import dt as dt_util from . import setup_integration +from tests.common import async_fire_time_changed + async def test_successful_config_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test config entry successful setup.""" @@ -45,6 +50,34 @@ async def test_hub_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR +async def test_connection_lost_during_refresh_raises_update_failed( + hass: HomeAssistant, mock_config_entry +) -> None: + """Test a lost connection during a scheduled refresh is treated as UpdateFailed. + + ConfigEntryNotReady is only special-cased on the first refresh; raising + it from later scheduled refreshes falls through to the coordinator's + generic exception handler instead of the dedicated UpdateFailed one. + """ + entry = mock_config_entry() + await setup_integration(hass, entry, command_responses={}) + assert entry.state is ConfigEntryState.LOADED + + coordinator = entry.runtime_data + + with patch.object( + entry.runtime_data.api, + "command", + side_effect=OSError(113, "Host is unreachable"), + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) + + async def test_unload_entry(hass: HomeAssistant, mock_config_entry) -> None: """Test unloading an entry.""" entry = mock_config_entry()