feat: add opt-in periodic interface-change monitor · python-zeroconf/python-zeroconf@e406194 · GitHub
Skip to content

Commit e406194

Browse files
committed
feat: add opt-in periodic interface-change monitor
1 parent 86771c6 commit e406194

4 files changed

Lines changed: 266 additions & 0 deletions

File tree

src/zeroconf/_core.py

Lines changed: 25 additions & 0 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Optional periodic interface-change monitor.
2+
3+
Interface change detection is platform specific and is left to the consumer
4+
by default. This convenience monitor polls ``ifaddr.get_adapters`` and calls
5+
``Zeroconf.async_update_interfaces`` when the set of interface addresses
6+
changes, so a consumer that has no native change signal can still reconcile
7+
sockets without restarting the instance.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import contextlib
14+
from typing import TYPE_CHECKING
15+
16+
import ifaddr
17+
18+
from .._logger import log
19+
20+
if TYPE_CHECKING:
21+
from .._core import Zeroconf
22+
23+
_DEFAULT_INTERFACE_MONITOR_INTERVAL = 5.0 # seconds
24+
25+
26+
def _adapter_snapshot() -> frozenset[tuple[int | None, str]]:
27+
"""Return a hashable snapshot of every adapter index and address."""
28+
return frozenset((adapter.index, str(ip.ip)) for adapter in ifaddr.get_adapters() for ip in adapter.ips)
29+
30+
31+
class InterfaceMonitor:
32+
"""Poll for adapter changes and rescan interfaces when they change."""
33+
34+
__slots__ = ("_interval", "_snapshot", "_task", "_zc")
35+
36+
def __init__(self, zc: Zeroconf, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL) -> None:
37+
self._zc = zc
38+
self._interval = interval
39+
self._snapshot = _adapter_snapshot()
40+
self._task: asyncio.Task[None] | None = None
41+
42+
def start(self) -> None:
43+
"""Start the poll task on the running loop."""
44+
assert self._zc.loop is not None
45+
if self._task is None:
46+
self._task = self._zc.loop.create_task(self._async_run())
47+
48+
async def async_stop(self) -> None:
49+
"""Cancel the poll task and wait for it to finish."""
50+
task = self._task
51+
if task is None:
52+
return
53+
self._task = None
54+
task.cancel()
55+
with contextlib.suppress(asyncio.CancelledError):
56+
await task
57+
58+
async def _async_run(self) -> None:
59+
"""Rescan interfaces whenever the adapter snapshot changes."""
60+
while True:
61+
await asyncio.sleep(self._interval)
62+
snapshot = _adapter_snapshot()
63+
if snapshot == self._snapshot:
64+
continue
65+
self._snapshot = snapshot
66+
try:
67+
await self._zc.async_update_interfaces()
68+
except Exception:
69+
# A transient failure must not kill the monitor; the next
70+
# change still triggers a rescan.
71+
log.exception("Interface rescan failed")

src/zeroconf/asyncio.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ._services.browser import _ServiceBrowserBase
3535
from ._services.info import AsyncServiceInfo, ServiceInfo
3636
from ._services.types import ZeroconfServiceTypes
37+
from ._utils.interface_monitor import _DEFAULT_INTERFACE_MONITOR_INTERVAL
3738
from ._utils.net import InterfaceChoice, InterfacesType, IPVersion
3839
from .const import _BROWSER_TIME, _MDNS_PORT, _SERVICE_TYPE_ENUMERATION_NAME
3940

@@ -240,6 +241,21 @@ async def async_update_interfaces(
240241
"""
241242
await self.zeroconf.async_update_interfaces(interfaces, ip_version, apple_p2p)
242243

244+
async def async_start_interface_monitor(
245+
self, interval: float = _DEFAULT_INTERFACE_MONITOR_INTERVAL
246+
) -> None:
247+
"""Start an opt-in poller that rescans interfaces when adapters change.
248+
249+
Interface change detection is platform specific; by default zeroconf
250+
leaves it to the consumer. This polls every ``interval`` seconds and
251+
reconciles the sockets in use when the address set changes.
252+
"""
253+
await self.zeroconf.async_start_interface_monitor(interval)
254+
255+
async def async_stop_interface_monitor(self) -> None:
256+
"""Stop the interface monitor if it is running."""
257+
await self.zeroconf.async_stop_interface_monitor()
258+
243259
async def async_close(self) -> None:
244260
"""Ends the background threads, and prevent this instance from
245261
servicing further queries."""

tests/test_interface_monitor.py

Lines changed: 154 additions & 0 deletions

0 commit comments

Comments
 (0)