From 21a6e6d2de47b337097ed094e112454930d0eb35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 12:50:43 -0500 Subject: [PATCH 1/3] docs: add an example for re-announcing on interface changes --- examples/async_interface_monitor.py | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100755 examples/async_interface_monitor.py diff --git a/examples/async_interface_monitor.py b/examples/async_interface_monitor.py new file mode 100755 index 00000000..7ce7464e --- /dev/null +++ b/examples/async_interface_monitor.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python + +"""Re-announce services when the host's network interfaces change. + +``AsyncZeroconf.async_update_interfaces()`` reconciles the sockets in use to the +live interface set: it binds responders for interfaces that appeared, tears down +the ones that went away, and re-announces existing registrations on the new +senders. A call where nothing changed is a no-op. + +zeroconf does not poll for interface changes itself; detection is +platform-specific and is best driven from whatever signal a host already has (a +netlink subscription on Linux, a framework's adapter-change event, etc.). When +no such signal is available, a small periodic poller like the one below is +enough: snapshot the addresses and reconcile only when they change. The piece +worth copying carefully is the lifecycle, cancel the monitor task before +closing the AsyncZeroconf so it does not outlive the instance. +""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging + +from zeroconf import get_all_addresses, get_all_addresses_v6 +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf + + +def address_snapshot() -> set[object]: + """A hashable snapshot of the host's current IPv4 and IPv6 addresses.""" + return {*get_all_addresses(), *get_all_addresses_v6()} + + +async def monitor_interfaces(aiozc: AsyncZeroconf, interval: float) -> None: + """Reconcile sockets whenever the host's addresses change, until cancelled.""" + previous = address_snapshot() + while True: + await asyncio.sleep(interval) + current = address_snapshot() + if current != previous: + previous = current + print("Interfaces changed, reconciling sockets...") + await aiozc.async_update_interfaces() + + +class AsyncRunner: + def __init__(self) -> None: + self.aiozc: AsyncZeroconf | None = None + self.monitor: asyncio.Task | None = None + + async def run(self, info: AsyncServiceInfo, interval: float) -> None: + self.aiozc = AsyncZeroconf() + await self.aiozc.async_register_service(info) + self.monitor = asyncio.ensure_future(monitor_interfaces(self.aiozc, interval)) + print("Registered; monitoring interfaces. Press Ctrl-C to exit...") + await asyncio.Event().wait() + + async def close(self, info: AsyncServiceInfo) -> None: + assert self.aiozc is not None + # Stop the monitor before closing so it can't reconcile a closed instance. + if self.monitor is not None: + self.monitor.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self.monitor + await self.aiozc.async_unregister_service(info) + await self.aiozc.async_close() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + parser = argparse.ArgumentParser() + parser.add_argument("--debug", action="store_true") + parser.add_argument("--interval", type=float, default=30.0, help="poll seconds") + args = parser.parse_args() + if args.debug: + logging.getLogger("zeroconf").setLevel(logging.DEBUG) + + info = AsyncServiceInfo( + "_http._tcp.local.", + "Interface Monitor Demo._http._tcp.local.", + port=80, + properties={"path": "/"}, + server="interface-monitor-demo.local.", + ) + + loop = asyncio.get_event_loop() + runner = AsyncRunner() + try: + loop.run_until_complete(runner.run(info, args.interval)) + except KeyboardInterrupt: + loop.run_until_complete(runner.close(info)) From f699df72ddd0a7aab84326bdbf922db4f6e056a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 12:53:50 -0500 Subject: [PATCH 2/3] docs: use asyncio.create_task in the interface monitor example --- examples/async_interface_monitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/async_interface_monitor.py b/examples/async_interface_monitor.py index 7ce7464e..07b8d4d4 100755 --- a/examples/async_interface_monitor.py +++ b/examples/async_interface_monitor.py @@ -52,7 +52,7 @@ def __init__(self) -> None: async def run(self, info: AsyncServiceInfo, interval: float) -> None: self.aiozc = AsyncZeroconf() await self.aiozc.async_register_service(info) - self.monitor = asyncio.ensure_future(monitor_interfaces(self.aiozc, interval)) + self.monitor = asyncio.create_task(monitor_interfaces(self.aiozc, interval)) print("Registered; monitoring interfaces. Press Ctrl-C to exit...") await asyncio.Event().wait() From 71ffff34357702181f4784ace7e85c800b3ffe12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jun 2026 13:06:45 -0500 Subject: [PATCH 3/3] docs: use ifaddr directly, harden the monitor loop, await the goodbye --- examples/async_interface_monitor.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/examples/async_interface_monitor.py b/examples/async_interface_monitor.py index 07b8d4d4..b19e6010 100755 --- a/examples/async_interface_monitor.py +++ b/examples/async_interface_monitor.py @@ -23,13 +23,16 @@ import contextlib import logging -from zeroconf import get_all_addresses, get_all_addresses_v6 +import ifaddr + from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +_LOGGER = logging.getLogger("interface_monitor") + -def address_snapshot() -> set[object]: - """A hashable snapshot of the host's current IPv4 and IPv6 addresses.""" - return {*get_all_addresses(), *get_all_addresses_v6()} +def address_snapshot() -> set[tuple[str, int]]: + """A snapshot of the host's current addresses; a change triggers a reconcile.""" + return {(str(ip.ip), ip.network_prefix) for adapter in ifaddr.get_adapters() for ip in adapter.ips} async def monitor_interfaces(aiozc: AsyncZeroconf, interval: float) -> None: @@ -38,10 +41,17 @@ async def monitor_interfaces(aiozc: AsyncZeroconf, interval: float) -> None: while True: await asyncio.sleep(interval) current = address_snapshot() - if current != previous: - previous = current + if current == previous: + continue + try: print("Interfaces changed, reconciling sockets...") await aiozc.async_update_interfaces() + except Exception: + # Log and retry on the next tick rather than letting the monitor + # die; leave ``previous`` unchanged so the change is re-attempted. + _LOGGER.exception("Interface reconcile failed; will retry") + else: + previous = current class AsyncRunner: @@ -63,7 +73,8 @@ async def close(self, info: AsyncServiceInfo) -> None: self.monitor.cancel() with contextlib.suppress(asyncio.CancelledError): await self.monitor - await self.aiozc.async_unregister_service(info) + # Await the goodbye broadcast so the TTL-0 records are actually sent. + await (await self.aiozc.async_unregister_service(info)) await self.aiozc.async_close()