From b5dc1e30cbd9577fff0592f232a99aa57d5196f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jun 2026 13:00:27 -0500 Subject: [PATCH 1/2] Advertise dashboard over mDNS in HA addon mode The addon runs with host networking, so the mDNS announce reaches the LAN; the Desktop app can now discover that a builder is running. Filter the Supervisor hassio bridge out of the announced addresses, the same way docker0 already was, so we never advertise an unreachable 172.30.32.x address. The advertise is discovery only; the peer-link receiver stays default off on the addon. --- esphome_device_builder/device_builder.py | 26 +++++------- .../helpers/dashboard_advertise.py | 6 ++- tests/test_dashboard_advertise.py | 40 ++++++++++++++----- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/esphome_device_builder/device_builder.py b/esphome_device_builder/device_builder.py index fe46e7cc..ba5b07e8 100644 --- a/esphome_device_builder/device_builder.py +++ b/esphome_device_builder/device_builder.py @@ -346,24 +346,20 @@ async def start(self) -> None: # Reuses the state monitor's zeroconf instance so the # responder count stays at one per process. # - # Skipped in two cases: - # * Zeroconf failed to bind — device discovery already - # fails soft here, the advertise follows the same rule. - # * HA addon — by default the addon container's port 6052 - # is not exposed to the LAN (ingress-only on 8099) AND - # mDNS announcements would carry the container's docker - # IP rather than the host's, so a peer that found the - # listing couldn't connect anyway. A future setting can - # opt back in once we know how to expose the addon's - # host port deliberately. + # Advertised in HA addon mode too: the addon runs with host + # networking, so the announce carries the host's LAN IP (the + # ``hassio`` Supervisor bridge is filtered out in + # ``_local_addresses``) and reaches peers. The advertise is + # discovery-only — it tells the Desktop a builder exists; it + # does not imply a peer-link receiver is bound (that stays + # default-off on the addon, see ``maybe_start`` below). + # + # Skipped only when zeroconf failed to bind — device + # discovery already fails soft here, the advertise follows + # the same rule. zeroconf = self.devices.zeroconf if zeroconf is None: _LOGGER.debug("Skipping dashboard mDNS advertise: zeroconf is unavailable") - elif self.settings.on_ha_addon: - _LOGGER.debug( - "Skipping dashboard mDNS advertise: running as HA addon " - "(ingress-only; port 6052 not LAN-reachable)" - ) else: # ``dashboard_id`` makes the SRV target collision-free # ({short_hostname}-{short_dashboard_id}.local) so two diff --git a/esphome_device_builder/helpers/dashboard_advertise.py b/esphome_device_builder/helpers/dashboard_advertise.py index ac03801a..f3c6e2b8 100644 --- a/esphome_device_builder/helpers/dashboard_advertise.py +++ b/esphome_device_builder/helpers/dashboard_advertise.py @@ -121,9 +121,13 @@ def _is_loopback_adapter(adapter: ifaddr.Adapter) -> bool: # Interface-name prefixes for virtualisation / container bridges. # Their IPs are host-namespace-scoped; advertising e.g. ``docker0`` # at ``172.17.0.1`` directs peers with the same Docker default -# subnet at their own bridge gateway. +# subnet at their own bridge gateway. The HA addon runs with host +# networking, so the container also sees the Supervisor ``hassio`` +# bridge (``172.30.32.0/23``); it is host-internal like ``docker0`` +# and must not reach the wire. _VIRTUAL_BRIDGE_PREFIXES: tuple[str, ...] = ( "docker", # docker0, docker_gwbridge + "hassio", # HA Supervisor internal bridge (172.30.32.0/23) "veth", # virtual ethernet pair peer "cni", # Kubernetes CNI plugin bridges "virbr", # libvirt default bridges diff --git a/tests/test_dashboard_advertise.py b/tests/test_dashboard_advertise.py index 4d73d513..0d70b785 100644 --- a/tests/test_dashboard_advertise.py +++ b/tests/test_dashboard_advertise.py @@ -317,12 +317,13 @@ def test_local_addresses_drops_loopback_ip_on_real_adapter(monkeypatch: pytest.M [ "docker0", "docker_gwbridge", + "hassio", "br-1a2b3c4d5e6f", "veth1234abcd", "cni0", "virbr0", ], - ids=["docker0", "docker-named", "br-userdef", "veth-peer", "cni0", "virbr0"], + ids=["docker0", "docker-named", "hassio", "br-userdef", "veth-peer", "cni0", "virbr0"], ) def test_local_addresses_drops_virtual_bridge_by_name( monkeypatch: pytest.MonkeyPatch, bridge_name: str @@ -336,6 +337,18 @@ def test_local_addresses_drops_virtual_bridge_by_name( assert _local_addresses() == ["192.168.1.10"] +def test_local_addresses_ha_addon_layout(monkeypatch: pytest.MonkeyPatch) -> None: + """Host-networking addon: only the real LAN NIC survives, not the Supervisor bridges.""" + adapters = [ + _adapter("enp89s0", ips=["192.168.208.10"]), + _adapter("hassio", ips=["172.30.32.1"]), + _adapter("docker0", ips=["172.30.232.1"]), + _adapter("veth3eb170d", ips=["169.254.0.1"]), + ] + monkeypatch.setattr(dashboard_advertise.ifaddr, "get_adapters", lambda: adapters) + assert _local_addresses() == ["192.168.208.10"] + + def test_local_addresses_drops_hyperv_virtual_switch_by_nice_name( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1045,39 +1058,46 @@ def __init__(self, **kwargs: object) -> None: assert constructed == [], "advertise must skip when zeroconf is None" -async def test_device_builder_skips_advertise_in_ha_addon_mode( +async def test_device_builder_advertises_in_ha_addon_mode( monkeypatch: pytest.MonkeyPatch, make_settings, _hermetic_lifecycle, ) -> None: - """``on_ha_addon=True`` → advertise is skipped even with zeroconf up. + """``on_ha_addon=True`` → advertise still registers (host-networking addon). Mocks the state monitor's ``zeroconf`` accessor to return a live - object so the only thing standing between ``start()`` and a - ``DashboardAdvertiser`` construction is the addon-mode guard. + object; the addon-mode advertise is discovery-only and no longer + gated, so ``start()`` must construct and register the advertiser. """ - constructed: list[object] = [] + fake_zc = MagicMock() + instances: list[object] = [] class _FakeAdvertiser: def __init__(self, **kwargs: object) -> None: - constructed.append(kwargs) + self.kwargs = kwargs self.register = AsyncMock() self.registered = False self.unregister = AsyncMock() + self.set_pin_sha256 = MagicMock() + self.set_remote_build_port = MagicMock() + self.refresh = AsyncMock() + instances.append(self) monkeypatch.setattr(db_module, "DashboardAdvertiser", _FakeAdvertiser) - monkeypatch.setattr(DeviceStateMonitor, "zeroconf", property(lambda self: MagicMock())) + monkeypatch.setattr(DeviceStateMonitor, "zeroconf", property(lambda self: fake_zc)) settings = make_settings(with_core_path=True) settings.on_ha_addon = True + # Ephemeral peer-link port so an opportunistic bind can't collide. + settings.remote_build_port = 0 db = DeviceBuilder(settings) try: await db.start() + assert len(instances) == 1 + instances[0].register.assert_awaited_once_with(fake_zc) # type: ignore[attr-defined] finally: await db.stop() - assert constructed == [], "advertise must be skipped in HA addon mode" - async def test_device_builder_constructs_advertiser_when_zeroconf_present( monkeypatch: pytest.MonkeyPatch, From aba4da7dd88d96df1f44f0d5e0a922629fb397bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 9 Jun 2026 13:20:53 -0500 Subject: [PATCH 2/2] Assert addon advertise stays discovery-only --- tests/test_dashboard_advertise.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_dashboard_advertise.py b/tests/test_dashboard_advertise.py index 0d70b785..3080840c 100644 --- a/tests/test_dashboard_advertise.py +++ b/tests/test_dashboard_advertise.py @@ -1094,7 +1094,13 @@ def __init__(self, **kwargs: object) -> None: try: await db.start() assert len(instances) == 1 - instances[0].register.assert_awaited_once_with(fake_zc) # type: ignore[attr-defined] + adv = instances[0] + adv.register.assert_awaited_once_with(fake_zc) # type: ignore[attr-defined] + # Discovery-only: the addon's peer-link receiver stays + # default-off, so no pin / port is pushed into the advertise. + adv.set_pin_sha256.assert_not_called() # type: ignore[attr-defined] + adv.set_remote_build_port.assert_not_called() # type: ignore[attr-defined] + adv.refresh.assert_not_awaited() # type: ignore[attr-defined] finally: await db.stop()