Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
7537e9216e Add IP change callback system for network adapters
Implements a callback system that allows integrations to be notified when
network IP addresses change.

Network Integration:
- Add NetworkChangeCallback type for type-safe callback registration
- Add async_register_change_callback() to register network change listeners
- Add async_notify_network_change() to trigger adapter reload and notify callbacks
- Callbacks receive the updated list of adapters when changes occur
- Callbacks are only invoked if adapters actually changed

Hassio Integration:
- Add EVENT_NETWORK_CHANGED constant for supervisor websocket events
- Listen for network_changed events from supervisor via existing websocket
- Call network integration's notification method when event received

This allows integrations to register callbacks and be notified when the
supervisor detects network changes (DHCP renewal, interface changes, etc.).
2026-01-10 02:33:27 +00:00
6 changed files with 186 additions and 2 deletions

View File

@@ -16,7 +16,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import panel_custom
from homeassistant.components import network, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -41,6 +41,7 @@ from homeassistant.helpers import (
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
@@ -78,6 +79,7 @@ from .const import (
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_CORE_INFO,
@@ -89,6 +91,8 @@ from .const import (
DATA_STORE,
DATA_SUPERVISOR_INFO,
DOMAIN,
EVENT_NETWORK_CHANGED,
EVENT_SUPERVISOR_EVENT,
HASSIO_UPDATE_INTERVAL,
)
from .coordinator import (
@@ -380,6 +384,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
@callback
def _async_handle_supervisor_events(event: dict[str, Any]) -> None:
"""Handle supervisor events for network changes."""
if event.get(ATTR_WS_EVENT) == EVENT_NETWORK_CHANGED:
hass.async_create_task(network.async_notify_network_change(hass))
async_dispatcher_connect(
hass, EVENT_SUPERVISOR_EVENT, _async_handle_supervisor_events
)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
api_endpoint = MAP_SERVICE_API[service.service]

View File

@@ -70,6 +70,7 @@ EVENT_HEALTH_CHANGED = "health_changed"
EVENT_SUPPORTED_CHANGED = "supported_changed"
EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_NETWORK_CHANGED = "network_changed"
EVENT_JOB = "job"
UPDATE_KEY_SUPERVISOR = "supervisor"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
from ipaddress import IPv4Address, IPv6Address, ip_interface
import logging
from pathlib import Path
@@ -22,7 +23,12 @@ from .const import (
PUBLIC_TARGET_IP,
)
from .models import Adapter
from .network import Network, async_get_loaded_network, async_get_network
from .network import (
Network,
NetworkChangeCallback,
async_get_loaded_network,
async_get_network,
)
_LOGGER = logging.getLogger(__name__)
@@ -172,6 +178,32 @@ async def async_get_announce_addresses(hass: HomeAssistant) -> list[str]:
return list(addresses)
@callback
def async_register_network_change_callback(
hass: HomeAssistant, callback_fn: NetworkChangeCallback
) -> Callable[[], None]:
"""Register a callback to be called when network adapters change.
Returns a function to unregister the callback.
The callback will be called with the new list of adapters when
a network change is detected.
"""
network: Network = async_get_loaded_network(hass)
return network.async_register_change_callback(callback_fn)
async def async_notify_network_change(hass: HomeAssistant) -> None:
"""Notify the network integration of a network change.
This will reload network adapters and notify all registered callbacks.
This should be called when external systems (like the supervisor) detect
a network change.
"""
network: Network = await async_get_network(hass)
await network.async_notify_network_change()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up network for Home Assistant."""
# Avoid circular issue: http->network->websocket_api->http

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Any
@@ -23,6 +24,8 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada
_LOGGER = logging.getLogger(__name__)
type NetworkChangeCallback = Callable[[list[Adapter]], None]
DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN)
@@ -48,11 +51,13 @@ class Network:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the Network class."""
self._hass = hass
self._store = Store[dict[str, list[str]]](
hass, STORAGE_VERSION, STORAGE_KEY, atomic_writes=True
)
self._data: dict[str, list[str]] = {}
self.adapters: list[Adapter] = []
self._change_callbacks: list[NetworkChangeCallback] = []
@property
def configured_adapters(self) -> list[str]:
@@ -85,3 +90,34 @@ class Network:
async def _async_save(self) -> None:
"""Save preferences."""
await self._store.async_save(self._data)
@callback
def async_register_change_callback(
self, callback_fn: NetworkChangeCallback
) -> Callable[[], None]:
"""Register a callback to be called when network adapters change.
Returns a function to unregister the callback.
"""
self._change_callbacks.append(callback_fn)
@callback
def unregister() -> None:
"""Unregister the callback."""
self._change_callbacks.remove(callback_fn)
return unregister
async def async_notify_network_change(self) -> None:
"""Notify listeners of a network change.
This reloads network adapters and calls all registered callbacks.
"""
old_adapters = self.adapters
self.adapters = await async_load_adapters()
self.async_configure()
if old_adapters != self.adapters:
_LOGGER.info("Network adapters changed: %s", self.adapters)
for callback_fn in self._change_callbacks:
callback_fn(self.adapters)

View File

@@ -22,12 +22,16 @@ from homeassistant.components.hassio import (
)
from homeassistant.components.hassio.config import STORAGE_KEY
from homeassistant.components.hassio.const import (
ATTR_WS_EVENT,
EVENT_NETWORK_CHANGED,
EVENT_SUPERVISOR_EVENT,
HASSIO_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.hassio import is_hassio
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -1388,3 +1392,26 @@ async def test_deprecated_installation_issue_supported_board(
await hass.async_block_till_done()
assert len(issue_registry.issues) == 0
async def test_network_change_event(hass: HomeAssistant) -> None:
"""Test network change event from supervisor triggers network notification."""
with (
patch.dict(os.environ, MOCK_ENVIRON),
patch(
"homeassistant.components.hassio.HassIO.is_connected",
return_value=True,
),
patch(
"homeassistant.components.network.async_notify_network_change"
) as mock_notify,
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
async_dispatcher_send(
hass, EVENT_SUPERVISOR_EVENT, {ATTR_WS_EVENT: EVENT_NETWORK_CHANGED}
)
await hass.async_block_till_done()
mock_notify.assert_called_once_with(hass)

View File

@@ -850,3 +850,77 @@ async def test_repair_docker_host_network_without_host_networking(
assert (issue := issue_registry.async_get_issue(DOMAIN, "docker_host_network"))
assert issue == snapshot
@pytest.mark.parametrize(
"mock_socket",
[
[
(MDNS_TARGET_IP, _mock_cond_socket(NO_LOOPBACK_IPADDR)),
]
],
indirect=True,
)
@pytest.mark.usefixtures("mock_socket")
async def test_network_change_callbacks(hass: HomeAssistant) -> None:
"""Test network change callbacks are called only when adapters change."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
callback_calls = []
def mock_callback(adapters):
"""Mock callback to track calls."""
callback_calls.append(adapters)
# Register callback
unregister = network.async_register_network_change_callback(hass, mock_callback)
# Get initial adapters
network_obj = hass.data[DOMAIN]
initial_adapters = network_obj.adapters
# Trigger network change with same adapters - callback should NOT be called
with patch(
"homeassistant.components.network.util.async_load_adapters",
return_value=initial_adapters,
):
await network.async_notify_network_change(hass)
await hass.async_block_till_done()
assert len(callback_calls) == 0
# Trigger network change with different adapters - callback SHOULD be called
new_adapters = [
{
"index": 2,
"auto": True,
"default": True,
"enabled": True,
"ipv4": [{"address": "192.168.2.10", "network_prefix": 24}],
"ipv6": [],
"name": "eth1",
}
]
with patch(
"homeassistant.components.network.util.async_load_adapters",
return_value=new_adapters,
):
await network.async_notify_network_change(hass)
await hass.async_block_till_done()
assert len(callback_calls) == 1
assert callback_calls[0] == new_adapters
# Unregister callback
unregister()
# Trigger another change - callback should NOT be called
with patch(
"homeassistant.components.network.util.async_load_adapters",
return_value=initial_adapters,
):
await network.async_notify_network_change(hass)
await hass.async_block_till_done()
assert len(callback_calls) == 1