Compare commits

...

6 Commits

12 changed files with 172 additions and 5 deletions
+23 -1
View File
@@ -5,6 +5,7 @@ from functools import partial
import logging
import os
import struct
from typing import Any
from aiohasupervisor import SupervisorBadRequestError, SupervisorError
from aiohasupervisor.models import (
@@ -16,7 +17,7 @@ from aiohasupervisor.models import (
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken, User
from homeassistant.components import frontend
from homeassistant.components import frontend, network
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.onboarding import async_is_onboarded
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -30,6 +31,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.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
@@ -54,14 +56,19 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
DATA_COMPONENT,
DATA_CONFIG_STORE,
DATA_HASSIO_HOST,
DATA_HASSIO_SUPERVISOR_USER,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
MAIN_COORDINATOR,
STATS_COORDINATOR,
UPDATE_KEY_NETWORK,
)
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
@@ -385,6 +392,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.async_on_unload(hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config))
@callback
def _async_supervisor_event(event: dict[str, Any]) -> None:
"""Reload network adapters when Supervisor reports a network change."""
if (
event.get(ATTR_WS_EVENT) == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_NETWORK
):
entry.async_create_background_task(
hass, network.async_reload_adapters(hass), "hassio_reload_adapters"
)
entry.async_on_unload(
async_dispatcher_connect(hass, EVENT_SUPERVISOR_EVENT, _async_supervisor_event)
)
async def update_hass_api(refresh_token: RefreshToken) -> None:
"""Update Home Assistant API data on Hass.io."""
# hass.config.api is always set here: hassio depends on http, and the
+1
View File
@@ -91,6 +91,7 @@ EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
EVENT_JOB = "job"
UPDATE_KEY_NETWORK = "network"
UPDATE_KEY_SUPERVISOR = "supervisor"
STARTUP_COMPLETE = "complete"
@@ -1,6 +1,7 @@
{
"domain": "hassio",
"name": "Home Assistant Supervisor",
"after_dependencies": ["network"],
"codeowners": ["@home-assistant/supervisor"],
"dependencies": ["http", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/hassio",
+11 -1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.util import package
@@ -17,9 +18,10 @@ from .const import (
LOOPBACK_TARGET_IP,
MDNS_TARGET_IP,
PUBLIC_TARGET_IP,
SIGNAL_NETWORK_ADAPTERS_CHANGED,
)
from .models import Adapter
from .network import Network, async_get_loaded_network, async_get_network
from .network import DATA_NETWORK, Network, async_get_loaded_network, async_get_network
_LOGGER = logging.getLogger(__name__)
@@ -51,6 +53,14 @@ def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
return async_get_loaded_network(hass).adapters
async def async_reload_adapters(hass: HomeAssistant) -> None:
"""Reload the network adapters and notify listeners if they changed."""
if DATA_NETWORK not in hass.data:
return
if await async_get_loaded_network(hass).async_reload():
async_dispatcher_send(hass, SIGNAL_NETWORK_ADAPTERS_CHANGED)
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
) -> str:
@@ -19,6 +19,8 @@ MDNS_TARGET_IP: Final = "224.0.0.251"
PUBLIC_TARGET_IP: Final = "8.8.8.8"
IPV4_BROADCAST_ADDR: Final = "255.255.255.255"
SIGNAL_NETWORK_ADAPTERS_CHANGED: Final = "network_adapters_changed"
NETWORK_CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(
@@ -63,6 +63,18 @@ class Network:
self.adapters = await async_load_adapters()
await storage_load_task
async def async_reload(self) -> bool:
"""Reload adapters from the system, returning True if they changed.
Freshly loaded adapters are unconfigured (all disabled), so the new
set must be configured before it can be compared against the current,
already configured adapters.
"""
previous = self.adapters
self.adapters = await async_load_adapters()
self.async_configure()
return self.adapters != previous
@callback
def async_configure(self) -> None:
"""Configure from storage."""
@@ -11,6 +11,7 @@ from zeroconf import InterfaceChoice, IPVersion
from zeroconf.asyncio import AsyncServiceInfo
from homeassistant.components import network
from homeassistant.components.network import SIGNAL_NETWORK_ADAPTERS_CHANGED
from homeassistant.const import (
EVENT_HOMEASSISTANT_CLOSE,
EVENT_HOMEASSISTANT_STOP,
@@ -18,6 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, instance_id
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_homekit, async_get_zeroconf
@@ -174,6 +176,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_DISCOVERY] = discovery
websocket_api.async_setup(hass)
@callback
def _async_adapters_changed() -> None:
"""Reconcile the zeroconf sockets when the network adapters change."""
zc_args = _async_get_zc_args(hass)
# async_update_interfaces serializes concurrent calls and no-ops when
# the interface set is unchanged, so overlapping tasks are safe.
hass.async_create_background_task(
aio_zc.async_update_interfaces(
interfaces=zc_args["interfaces"],
ip_version=zc_args["ip_version"],
),
"zeroconf_update_interfaces",
)
async_dispatcher_connect(
hass, SIGNAL_NETWORK_ADAPTERS_CHANGED, _async_adapters_changed
)
async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None:
"""Expose Home Assistant on zeroconf when it starts.
+33
View File
@@ -55,6 +55,7 @@ from homeassistant.components.hassio import (
from homeassistant.components.hassio.config import STORAGE_KEY
from homeassistant.components.hassio.const import (
DATA_KEY_SUPERVISOR_ISSUES,
EVENT_SUPERVISOR_EVENT,
HASSIO_MAIN_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
)
@@ -67,6 +68,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
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
@@ -252,6 +254,37 @@ async def test_setup_onboarding_supervisor_update_error(
supervisor_client.supervisor.update.assert_called_once()
@pytest.mark.usefixtures("supervisor_client")
@pytest.mark.parametrize(
("update_key", "expected_calls"),
[("network", 1), ("supervisor", 0)],
ids=["network", "supervisor"],
)
async def test_supervisor_network_event_reloads_adapters(
hass: HomeAssistant,
update_key: str,
expected_calls: int,
) -> None:
"""Test only a Supervisor network event reloads the network adapters."""
with patch.dict(os.environ, MOCK_ENVIRON):
assert await async_setup_component(hass, DOMAIN, {"hassio": {}})
await hass.async_block_till_done()
with patch("homeassistant.components.network.async_reload_adapters") as mock_reload:
async_dispatcher_send(
hass,
EVENT_SUPERVISOR_EVENT,
{
"event": "supervisor_update",
"update_key": update_key,
"data": {},
},
)
await hass.async_block_till_done()
assert len(mock_reload.mock_calls) == expected_calls
async def test_setup_app_panel(hass: HomeAssistant) -> None:
"""Test app panel is registered."""
with patch.dict(os.environ, MOCK_ENVIRON):
+48 -1
View File
@@ -1,5 +1,6 @@
"""Test the Network Configuration."""
from copy import deepcopy
from ipaddress import IPv4Address
from typing import Any
from unittest.mock import MagicMock, Mock, patch
@@ -13,12 +14,15 @@ from homeassistant.components.network.const import (
ATTR_CONFIGURED_ADAPTERS,
DOMAIN,
MDNS_TARGET_IP,
SIGNAL_NETWORK_ADAPTERS_CHANGED,
STORAGE_KEY,
STORAGE_VERSION,
)
from homeassistant.core import HomeAssistant
from homeassistant.components.network.network import async_get_loaded_network
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.setup import async_setup_component
from . import LOOPBACK_IPADDR, NO_LOOPBACK_IPADDR
@@ -700,6 +704,49 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [
]
@pytest.mark.usefixtures("mock_socket_loopback")
async def test_async_reload_adapters(hass: HomeAssistant) -> None:
"""Test reloading adapters dispatches a signal only when they change."""
with patch(
"homeassistant.components.network.network.async_load_adapters",
return_value=deepcopy(_ADAPTERS_WITH_MANUAL_CONFIG),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
signals: list[None] = []
@callback
def _track() -> None:
signals.append(None)
async_dispatcher_connect(hass, SIGNAL_NETWORK_ADAPTERS_CHANGED, _track)
# The same hardware loads unconfigured (all disabled); once configured it
# matches the current adapters, so no signal is dispatched.
unconfigured = deepcopy(_ADAPTERS_WITH_MANUAL_CONFIG)
for adapter in unconfigured:
adapter["enabled"] = False
with patch(
"homeassistant.components.network.network.async_load_adapters",
return_value=unconfigured,
):
await network.async_reload_adapters(hass)
assert signals == []
# A physically different adapter set dispatches a signal.
changed = deepcopy(_ADAPTERS_WITH_MANUAL_CONFIG)
changed[1]["ipv4"][0]["address"] = "192.168.1.99"
with patch(
"homeassistant.components.network.network.async_load_adapters",
return_value=changed,
):
await network.async_reload_adapters(hass)
assert len(signals) == 1
# Read the singleton directly; async_get_loaded_adapters is globally mocked.
assert async_get_loaded_network(hass).adapters == changed
async def test_async_get_announce_addresses(hass: HomeAssistant) -> None:
"""Test addresses for mDNS/etc announcement."""
first_ip = "172.16.1.5"
+18
View File
@@ -14,6 +14,7 @@ from zeroconf.asyncio import AsyncServiceInfo
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.components.network import SIGNAL_NETWORK_ADAPTERS_CHANGED
from homeassistant.components.zeroconf import discovery
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
@@ -25,6 +26,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.generated import zeroconf as zc_gen
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.setup import ATTR_COMPONENT, async_setup_component
from tests.common import MockConfigEntry, MockModule, mock_integration
@@ -199,6 +201,22 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> Non
assert await zeroconf.async_get_async_instance(hass) is mock_async_zeroconf
async def test_network_adapters_changed_updates_interfaces(
hass: HomeAssistant, mock_async_zeroconf: MagicMock
) -> None:
"""Test zeroconf reconciles its sockets when the network adapters change."""
with patch.object(hass.config_entries.flow, "async_init"):
assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
await hass.async_block_till_done()
async_dispatcher_send(hass, SIGNAL_NETWORK_ADAPTERS_CHANGED)
await hass.async_block_till_done()
assert mock_async_zeroconf.async_update_interfaces.mock_calls == [
call(interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only)
]
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_setup_with_overly_long_url_and_name(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
+1
View File
@@ -1486,6 +1486,7 @@ def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]:
zc.async_unregister_service = AsyncMock()
zc.async_register_service = AsyncMock()
zc.async_update_service = AsyncMock()
zc.async_update_interfaces = AsyncMock()
zc.zeroconf = Mock(spec=Zeroconf)
zc.zeroconf.async_wait_for_start = AsyncMock()
# DNSCache has strong Cython type checks, and MagicMock does not work
+2 -2
View File
@@ -586,8 +586,8 @@ async def test_discovery_requirements_mqtt(hass: HomeAssistant) -> None:
) as mock_process:
await async_get_integration_with_requirements(hass, "mqtt_comp")
assert len(mock_process.mock_calls) == 2
# one for mqtt and one for hassio
assert len(mock_process.mock_calls) == 3
# one for mqtt, one for hassio, and one for network (hassio after_dependency)
assert mock_process.mock_calls[0][1][1] == mqtt.requirements