Skip to content

Commit 0449025

Browse files
authored
Relax unique_id logic (#493)
1 parent 90ff907 commit 0449025

File tree

7 files changed

+81
-27
lines changed

7 files changed

+81
-27
lines changed

custom_components/opnsense/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
211211
)
212212

213213
# Trigger repair task and shutdown if device id has changed
214-
router_device_id: str = await client.get_device_unique_id()
214+
router_device_id: str | None = await client.get_device_unique_id(expected_id=config_device_id)
215215
_LOGGER.debug(
216216
"[init async_setup_entry]: config device id: %s, router device id: %s",
217217
config_device_id,

custom_components/opnsense/config_flow.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,16 @@ async def validate_input(
8888
user_input: MutableMapping[str, Any],
8989
config_step: str,
9090
errors: dict[str, Any],
91+
expected_id: str | None = None,
9192
) -> dict[str, Any]:
9293
"""Check user input for errors."""
9394
# filtered_user_input: MutableMapping[str, Any] = {key: value for key, value in user_input.items() if key != CONF_PASSWORD}
9495
# _LOGGER.debug("[validate_input] user_input: %s", filtered_user_input)
9596

9697
try:
97-
await _handle_user_input(hass=hass, user_input=user_input, config_step=config_step)
98+
await _handle_user_input(
99+
hass=hass, user_input=user_input, config_step=config_step, expected_id=expected_id
100+
)
98101
except BelowMinFirmware:
99102
_log_and_set_error(
100103
errors=errors,
@@ -258,7 +261,10 @@ def _validate_firmware_version(firmware_version: str) -> None:
258261

259262

260263
async def _handle_user_input(
261-
hass: HomeAssistant, user_input: MutableMapping[str, Any], config_step: str
264+
hass: HomeAssistant,
265+
user_input: MutableMapping[str, Any],
266+
config_step: str,
267+
expected_id: str | None = None,
262268
) -> None:
263269
"""Handle and validate the user input."""
264270
await _clean_and_parse_url(user_input)
@@ -305,7 +311,7 @@ async def _handle_user_input(
305311
if not user_input.get(CONF_NAME):
306312
user_input[CONF_NAME] = system_info.get("name") or "OPNsense"
307313

308-
user_input[CONF_DEVICE_UNIQUE_ID] = await client.get_device_unique_id()
314+
user_input[CONF_DEVICE_UNIQUE_ID] = await client.get_device_unique_id(expected_id=expected_id)
309315
_LOGGER.debug("[handle_user_input] Device Unique ID: %s", user_input[CONF_DEVICE_UNIQUE_ID])
310316

311317
if not user_input.get(CONF_DEVICE_UNIQUE_ID):
@@ -562,7 +568,11 @@ async def async_step_granular_sync(
562568
self._config.update(user_input)
563569
# _LOGGER.debug("[config_flow granular_sync] merged config: %s", self._config)
564570
errors = await validate_input(
565-
hass=self.hass, user_input=self._config, config_step="granular_sync", errors=errors
571+
hass=self.hass,
572+
user_input=self._config,
573+
config_step="granular_sync",
574+
errors=errors,
575+
expected_id=self._config.get(CONF_DEVICE_UNIQUE_ID),
566576
)
567577
if not errors:
568578
return self.async_create_entry(
@@ -592,7 +602,11 @@ async def async_step_reconfigure(
592602
self._config.update(user_input)
593603
# _LOGGER.debug("[config_flow reconfigure] merged config: %s", self._config)
594604
errors = await validate_input(
595-
hass=self.hass, user_input=self._config, config_step="reconfigure", errors=errors
605+
hass=self.hass,
606+
user_input=self._config,
607+
config_step="reconfigure",
608+
errors=errors,
609+
expected_id=self._config.get(CONF_DEVICE_UNIQUE_ID),
596610
)
597611

598612
if not errors:
@@ -701,7 +715,11 @@ async def async_step_granular_sync(
701715
self._config.update(user_input)
702716
# _LOGGER.debug("[options_flow granular_sync] merged user_input. config: %s. options: %s", self._config, self._options)
703717
errors = await validate_input(
704-
hass=self.hass, user_input=self._config, config_step="granular_sync", errors=errors
718+
hass=self.hass,
719+
user_input=self._config,
720+
config_step="granular_sync",
721+
errors=errors,
722+
expected_id=self._config.get(CONF_DEVICE_UNIQUE_ID),
705723
)
706724
if not errors:
707725
if self._options.get(CONF_DEVICE_TRACKER_ENABLED):

custom_components/opnsense/coordinator.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""OPNsense Coordinator."""
22

3-
from collections.abc import Mapping, MutableMapping
3+
from collections.abc import Callable, Mapping, MutableMapping
44
import copy
55
from datetime import timedelta
66
import logging
@@ -83,10 +83,14 @@ async def _get_states(self, categories: list) -> MutableMapping[str, Any]:
8383
state: MutableMapping[str, Any] = {}
8484
total_time: float = 0
8585
for cat in categories:
86-
method = getattr(self._client, cat.get("function", ""), None)
87-
if method:
86+
method_name: str = cat.get("function", "")
87+
method: Callable | None = getattr(self._client, method_name, None)
88+
if method is not None:
8889
start_time: float = time.perf_counter()
89-
state[cat.get("state_key")] = await method()
90+
if method_name == "get_device_unique_id":
91+
state[cat.get("state_key")] = await method(expected_id=self._device_unique_id)
92+
else:
93+
state[cat.get("state_key")] = await method()
9094
end_time: float = time.perf_counter()
9195
elapsed_time: float = end_time - start_time
9296
total_time += elapsed_time

custom_components/opnsense/pyopnsense/__init__.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -684,21 +684,28 @@ async def _filter_configure(self) -> None:
684684
await self._exec_php(script)
685685

686686
@_log_errors
687-
async def get_device_unique_id(self) -> str | None:
687+
async def get_device_unique_id(self, expected_id: str | None = None) -> str | None:
688688
"""Get the OPNsense Unique ID."""
689689
instances = await self._safe_list_get("/api/interfaces/overview/export")
690-
mac_addresses = [
691-
d.get("macaddr_hw") for d in instances if d.get("is_physical") and "macaddr_hw" in d
692-
]
690+
mac_addresses = {
691+
d.get("macaddr_hw").replace(":", "_").strip()
692+
for d in instances
693+
if d.get("is_physical") and d.get("macaddr_hw")
694+
}
693695

694-
unique_mac_addresses: list = sorted(set(mac_addresses))
695-
device_unique_id: str | None = unique_mac_addresses[0] if unique_mac_addresses else None
696-
if device_unique_id:
697-
device_unique_id_fmt = device_unique_id.replace(":", "_").strip()
698-
_LOGGER.debug("[get_device_unique_id] device_unique_id: %s", device_unique_id_fmt)
699-
return device_unique_id_fmt
700-
_LOGGER.debug("[get_device_unique_id] device_unique_id: None")
701-
return None
696+
if not mac_addresses:
697+
_LOGGER.debug("[get_device_unique_id] device_unique_id: None")
698+
return None
699+
700+
if expected_id and expected_id in mac_addresses:
701+
_LOGGER.debug(
702+
"[get_device_unique_id] device_unique_id (matched expected): %s", expected_id
703+
)
704+
return expected_id
705+
706+
device_unique_id = sorted(mac_addresses)[0]
707+
_LOGGER.debug("[get_device_unique_id] device_unique_id (first): %s", device_unique_id)
708+
return device_unique_id
702709

703710
@_log_errors
704711
async def get_system_info(self) -> MutableMapping[str, Any]:

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ def __init__(self, **kwargs):
561561
self._query_counts_reset = False
562562
self._query_counts = (1, 1)
563563

564-
async def get_device_unique_id(self):
564+
async def get_device_unique_id(self, expected_id: str | None = None):
565565
return self._device_id
566566

567567
async def get_host_firmware_version(self):
@@ -682,7 +682,7 @@ async def set_use_snake_case(self, initial: bool = False) -> None:
682682
async def get_system_info(self) -> dict:
683683
return {"name": "OPNsense"}
684684

685-
async def get_device_unique_id(self) -> str:
685+
async def get_device_unique_id(self, expected_id: str | None = None) -> str:
686686
return self._device_id
687687

688688
async def is_plugin_installed(self) -> bool:

tests/test_integration.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async def is_plugin_installed(self) -> bool: # for SYNC_ITEMS_REQUIRING_PLUGIN
6161
async def get_system_info(self) -> MutableMapping[str, Any]:
6262
return {"name": "OPNsenseTest"}
6363

64-
async def get_device_unique_id(self) -> str:
64+
async def get_device_unique_id(self, expected_id: str | None = None) -> str:
6565
return self._device_id
6666

6767
async def get_arp_table(self, resolve_hostnames: bool = False) -> list[dict[str, Any]]:
@@ -80,7 +80,9 @@ def __init__(self, device_id: str = "dev-runtime", firmware: str = "25.1") -> No
8080
self._firmware = firmware
8181
self._closed = False
8282

83-
async def get_device_unique_id(self) -> str: # used by setup & coordinator
83+
async def get_device_unique_id(
84+
self, expected_id: str | None = None
85+
) -> str: # used by setup & coordinator
8486
return self._device_id
8587

8688
async def get_host_firmware_version(self) -> str: # used by setup & coordinator

tests/test_pyopnsense.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3296,6 +3296,29 @@ async def test_get_device_unique_id_no_mac(make_client) -> None:
32963296
await client.async_close()
32973297

32983298

3299+
@pytest.mark.asyncio
3300+
async def test_get_device_unique_id_expected(make_client) -> None:
3301+
"""get_device_unique_id returns expected_id if present even if not the first."""
3302+
session = MagicMock(spec=aiohttp.ClientSession)
3303+
client = make_client(session=session)
3304+
# aa_bb_cc is smaller than bb_cc_dd
3305+
client._safe_list_get = AsyncMock(
3306+
return_value=[
3307+
{"is_physical": True, "macaddr_hw": "aa:bb:cc"},
3308+
{"is_physical": True, "macaddr_hw": "bb:cc:dd"},
3309+
]
3310+
)
3311+
# Without expected_id, it returns the first one (aa_bb_cc)
3312+
assert await client.get_device_unique_id() == "aa_bb_cc"
3313+
3314+
# With expected_id bb_cc_dd, it returns bb_cc_dd even if aa_bb_cc is smaller
3315+
assert await client.get_device_unique_id(expected_id="bb_cc_dd") == "bb_cc_dd"
3316+
3317+
# With expected_id not present, it returns the first one
3318+
assert await client.get_device_unique_id(expected_id="cc_dd_ee") == "aa_bb_cc"
3319+
await client.async_close()
3320+
3321+
32993322
@pytest.mark.asyncio
33003323
async def test_get_firewall_legacy_fallback(make_client) -> None:
33013324
"""get_firewall falls back to legacy config for OPNsense < 26.1.1."""

0 commit comments

Comments
 (0)