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..3080840c 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,52 @@ 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 + 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() - assert constructed == [], "advertise must be skipped in HA addon mode" - async def test_device_builder_constructs_advertiser_when_zeroconf_present( monkeypatch: pytest.MonkeyPatch,