Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions esphome_device_builder/device_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion esphome_device_builder/helpers/dashboard_advertise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 36 additions & 10 deletions tests/test_dashboard_advertise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
Loading