Files
esphome/tests/unit_tests/test_zeroconf.py
2026-05-03 20:01:51 -05:00

238 lines
8.4 KiB
Python

"""Unit tests for ``esphome.zeroconf`` device-discovery primitives.
Covers ``DashboardImportDiscovery`` (state transitions for adoption /
import flows) and ``DiscoveredImport`` (TXT-record parse shape). Both
are part of the cross-tool contract between the legacy dashboard and
the new device-builder backend (esphome/device-builder); changes to
the callback signature, the ``import_state`` dict shape, or the
``DiscoveredImport`` field set will break downstream consumers.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from zeroconf import ServiceStateChange
from esphome.zeroconf import (
ESPHOME_SERVICE_TYPE,
DashboardImportDiscovery,
DiscoveredImport,
)
def _make_service_info(
package_import_url: str = "github://esphome/example/example.yaml",
project_name: str = "esphome.example",
project_version: str = "1.0.0",
network: str | None = "wifi",
friendly_name: str | None = "Living Room",
version: str | None = "2025.1.0",
) -> MagicMock:
"""Build a fake ``AsyncServiceInfo`` with the TXT records we care about.
The real callback path resolves a service via zeroconf and then
reads ``info.properties`` (a ``dict[bytes, bytes | None]``). Mock
that shape so we can drive ``_process_service_info`` directly
without spinning up a real zeroconf instance.
"""
info = MagicMock()
properties: dict[bytes, bytes | None] = {
b"package_import_url": package_import_url.encode(),
b"project_name": project_name.encode(),
b"project_version": project_version.encode(),
}
if network is not None:
properties[b"network"] = network.encode()
if friendly_name is not None:
properties[b"friendly_name"] = friendly_name.encode()
if version is not None:
properties[b"version"] = version.encode()
info.properties = properties
info.load_from_cache.return_value = True
return info
def test_added_service_populates_import_state_and_fires_callback() -> None:
"""An ADD with the required TXT records lands a ``DiscoveredImport`` and notifies.
Mirrors what both the legacy dashboard and device-builder rely
on — the callback is the only signal that an importable device
has appeared on the LAN, and ``import_state`` is the snapshot
they read on demand.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert name in discovery.import_state
entry = discovery.import_state[name]
assert isinstance(entry, DiscoveredImport)
assert entry.device_name == "living-room"
assert entry.package_import_url == "github://esphome/example/example.yaml"
assert entry.project_name == "esphome.example"
assert entry.project_version == "1.0.0"
assert entry.network == "wifi"
assert entry.friendly_name == "Living Room"
on_update.assert_called_once_with(name, entry)
def test_added_service_without_required_txt_is_ignored() -> None:
"""A device that doesn't carry ``package_import_url`` etc. isn't importable.
The dashboard browser also fires for plain ``_esphomelib._tcp``
services that happen to match the type but aren't dashboard
imports. Those must not land in ``import_state`` or fire the
update callback — otherwise the dashboard would surface every
API-enabled device on the LAN as "ready to adopt".
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = MagicMock()
# Empty TXT records — no import URL, no version. ``version``-only
# services hit a separate ``update_device_mdns`` path that talks
# to ``StorageJSON``; that's covered elsewhere.
info.properties = {}
info.load_from_cache.return_value = True
discovery._process_service_info(f"plain.{ESPHOME_SERVICE_TYPE}", info)
assert discovery.import_state == {}
on_update.assert_not_called()
def test_repeated_add_does_not_re_fire_callback() -> None:
"""Re-resolving the same service doesn't spam the on_update callback.
The dashboard re-resolves periodically; without the ``is_new``
guard, every refresh would fire ``IMPORTABLE_DEVICE_ADDED`` and
the dashboard's UI would re-render endlessly.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
discovery._process_service_info(name, info)
on_update.assert_called_once()
def test_removed_service_clears_state_and_fires_none_callback() -> None:
"""A ServiceStateChange.Removed pops the entry and notifies with ``None``.
Both consumers rely on the ``(name, None)`` callback shape to
distinguish "device gone" from "device updated". Coordinate
before changing the second-arg semantics.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
info = _make_service_info()
name = f"living-room.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
on_update.reset_mock()
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=name,
state_change=ServiceStateChange.Removed,
)
assert name not in discovery.import_state
on_update.assert_called_once_with(name, None)
def test_remove_for_unknown_service_does_not_fire_callback() -> None:
"""A spurious Removed for a service we never tracked is a silent no-op.
The browser can fire Removed for any matching service type,
not just the importable ones we're tracking. Don't let those
confuse the callback consumer.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=f"never-seen.{ESPHOME_SERVICE_TYPE}",
state_change=ServiceStateChange.Removed,
)
on_update.assert_not_called()
def test_updated_service_for_unknown_name_is_ignored() -> None:
"""Updates without a prior Add don't seed ``import_state``.
The dashboard counts on Add to introduce the device and Update
to refresh it. Letting Update silently introduce new state would
let an unrelated TXT change bypass the Add-time validation.
"""
on_update = MagicMock()
discovery = DashboardImportDiscovery(on_update=on_update)
discovery.browser_callback(
zeroconf=MagicMock(),
service_type=ESPHOME_SERVICE_TYPE,
name=f"living-room.{ESPHOME_SERVICE_TYPE}",
state_change=ServiceStateChange.Updated,
)
assert discovery.import_state == {}
on_update.assert_not_called()
def test_network_defaults_to_wifi_when_txt_absent() -> None:
"""Older firmware that doesn't broadcast ``network`` defaults to ``wifi``.
The TXT record was added in a later release; pre-existing
factory firmwares advertise without it. ``DiscoveredImport``
has to default cleanly so adoption flows can still produce a
valid YAML for those devices.
"""
discovery = DashboardImportDiscovery()
info = _make_service_info(network=None)
name = f"older.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert discovery.import_state[name].network == "wifi"
def test_friendly_name_optional() -> None:
"""``friendly_name`` may be ``None`` if the device doesn't broadcast it.
Both consumers handle the ``None`` case (rendering the device
name as fallback in the UI). Locking this in keeps the
optionality explicit so a future refactor doesn't accidentally
coerce it into an empty string.
"""
discovery = DashboardImportDiscovery()
info = _make_service_info(friendly_name=None)
name = f"no-friendly.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
assert discovery.import_state[name].friendly_name is None
def test_callback_is_optional() -> None:
"""``on_update=None`` lets ``import_state`` track silently.
Used by callers that read the dict directly rather than
subscribing to events.
"""
discovery = DashboardImportDiscovery(on_update=None)
info = _make_service_info()
name = f"silent.{ESPHOME_SERVICE_TYPE}"
discovery._process_service_info(name, info)
# No callback to assert against; just verify state landed.
assert name in discovery.import_state