Recover from broken connection for Mikrotik (#177285)

This commit is contained in:
Simone Chemelli
2026-07-27 13:36:14 +02:00
committed by GitHub
parent efe29065ae
commit ad52084b4d
6 changed files with 89 additions and 15 deletions
@@ -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
@@ -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))
+19 -3
View File
@@ -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)},
+1
View File
@@ -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, {})
@@ -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]:
+34 -1
View File
@@ -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()