mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 21:35:13 +01:00
Recover from broken connection for Mikrotik (#177285)
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user