Skip to content

Surface MAC + derived ethernet/bluetooth MACs from mDNS#338

Merged
bdraco merged 8 commits intomainfrom
mac-address-from-mdns
May 5, 2026
Merged

Surface MAC + derived ethernet/bluetooth MACs from mDNS#338
bdraco merged 8 commits intomainfrom
mac-address-from-mdns

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 5, 2026

What does this implement/fix?

Adds MAC-address surfacing to DevicesController / DeviceStateMonitor: the broadcast mac TXT on _esphomelib._tcp.local. lands on Device.mac_address (canonical XX:XX:XX:XX:XX:XX form, normalized at ingest), and devices with the ethernet or esp32_ble* / bluetooth_* integrations loaded also get a derived ethernet_mac / bluetooth_mac per Espressif's MAC allocation table (Ethernet = base + 3, Bluetooth = base + 2). RP2040 / RP2350 share a single MAC across interfaces, so ethernet_mac == mac_address there.

Same monitor → controller pipeline as the existing version / config_hash / api_encryption TXT records. The primary MAC is persisted to the per-device metadata sidecar (ip / expected_config_hash neighbour) so a freshly-restarted dashboard renders the value immediately — ESPHome devices stay mDNS-silent until probed, otherwise the column renders blank for the discovery sweep's bootstrap window. Persistence is gated on a real change (the existing device.mac_address == mac early-return covers both the in-memory write and the executor-bound set_device_metadata call) so the steady-state "same MAC every announce" cycle stays off-disk. mac_address joins ip / expected_config_hash in _VOLATILE_DEVICE_METADATA_FIELDS so archive scrubs it (the YAML may be redeployed to a different physical board on unarchive).

The derived MACs are deterministic from primary + loaded_integrations, so we recompute on every primary-MAC observation and on every load_device_from_storage rather than persisting — a YAML edit that toggles bluetooth picks up the new derived MAC on the next reload, no stale-cache window. _normalize_mac strips : / - / . separators, validates 12 hex chars, then re-inserts : between each octet so the form is idempotent regardless of which case / separator style the firmware happens to broadcast.

Espressif allocation reference: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/misc_system_api.html#mac-address-allocation

Related issue or feature (if applicable):

  • fixes

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.json has not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

bdraco added 2 commits May 5, 2026 10:04
Adds a new ``mac_address`` field on the ``Device`` model populated
from the ``mac`` TXT record on the device's
``_esphomelib._tcp.local.`` mDNS service. Mirrors the existing
``version`` / ``config_hash`` / ``api_encryption`` TXT pipelines:

- ``DeviceStateMonitor`` gains an ``apply_mac_address`` method and
  an ``on_mac_address_change`` callback. Values are normalized at
  ingest (``_normalize_mac`` strips ``:`` / ``-`` / ``.`` separators
  and lowercases) so the dedupe + sidecar stay canonical even if a
  future firmware switches case or separator style. Non-hex /
  wrong-length inputs are dropped — same shape as the empty-TXT
  no-op other apply-* methods use.

- The TXT extraction site at ``_apply_service_info_to_device``
  picks up ``props.get("mac")`` alongside ``version`` / ``config_hash``.

- ``DevicesController._on_mac_address_change`` writes through to
  the matching device(s), fires ``DEVICE_UPDATED``, and schedules
  a sidecar ``set_device_metadata(..., mac_address=mac)`` write —
  but only after the existing ``device.mac_address == mac`` early-
  return, so a steady-state mDNS announce of the same value is a
  no-op (no in-memory write, no executor-bound I/O). Persistence
  matters because ESPHome devices are mDNS-silent until probed:
  without the sidecar, the dashboard shows a blank MAC for the
  bootstrap window after every restart.

- ``mac_address`` joins ``ip`` and ``expected_config_hash`` in
  ``_VOLATILE_DEVICE_METADATA_FIELDS`` so the archive flow scrubs
  it (the YAML may be redeployed to a different physical board on
  unarchive; a stale persisted MAC would render until the next
  announce overwrote it).

13 new tests cover the monitor's first-observation / dedupe /
unknown-name / refire-after-rebuild / normalization / rejection
paths, the controller's persist-on-change + skip-on-unchanged /
skip-on-unknown branches, the sidecar round-trip + clear-on-empty
contract, and the archive volatility extension.
The dashboard already normalized at ingest, but the canonical form
was lowercase 12-hex-char. Switching to ``XX:XX:XX:XX:XX:XX``
(uppercase, colon-separated) so the in-memory model, sidecar, and
frontend wire all share the human-friendly form — no
frontend-side pretty-printer needed, callers reading the sidecar
or the WS event get a value that's already in the shape every
router admin / OUI lookup expects.

``_normalize_mac`` now strips ``:`` / ``-`` / ``.`` separators,
uppercases, validates 12 hex chars, then re-inserts ``:`` between
each octet. Idempotent: an already-canonical re-broadcast passes
through unchanged.

``derive_interface_macs`` likewise operates on canonical form
end-to-end (input + output), so ``ethernet_mac`` / ``bluetooth_mac``
share the wire shape. The 17-char length check rejects the older
compact form a caller might pass by mistake.

Test coverage extended with idempotence, separator-style and
already-canonical input branches, the
"new-primary-clears-stale-derived" branch, and the helper's
"reject uncanonical input" branch.
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 5, 2026

Merging this PR will not alter performance

✅ 9 untouched benchmarks


Comparing mac-address-from-mdns (bf3f3ab) with main (142957c)

Open in CodSpeed

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end surfacing of device MAC addresses from the _esphomelib._tcp.local. mDNS mac TXT record into the backend Device model, persists the primary MAC to the device metadata sidecar for fast display after restart, and deterministically derives Ethernet/Bluetooth MACs for certain platforms based on ESP-IDF allocation rules.

Changes:

  • Add MAC ingestion/normalization in DeviceStateMonitor and propagate updates through DevicesController (including sidecar persistence).
  • Introduce derive_interface_macs() helper to compute ethernet_mac / bluetooth_mac from primary MAC + platform + loaded integrations.
  • Expand device loading/scanning + metadata persistence/tests to include mac_address and archive-scrubbing behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/test_mdns_mac_address.py New tests for mDNS MAC ingestion, normalization, dedupe, controller persistence, and derivations.
tests/test_mac_address_derivation.py New unit tests covering derivation rules and edge cases.
tests/test_config_controller.py Adds sidecar persistence/clear tests for mac_address.
tests/controllers/devices/test_archive.py Ensures mac_address is treated as volatile and scrubbed on archive.
tests/conftest.py Adds callback plumbing for on_mac_address_change.
esphome_device_builder/models/devices.py Extends Device with mac_address, ethernet_mac, bluetooth_mac.
esphome_device_builder/helpers/mac_addresses.py New helper to derive interface MACs from the primary MAC.
esphome_device_builder/helpers/device_yaml.py Recomputes derived MACs during device load based on stored primary MAC + integrations.
esphome_device_builder/controllers/devices/controller.py Wires MAC callback, updates device fields, derives interface MACs, persists primary MAC.
esphome_device_builder/controllers/config.py Persists/clears mac_address and marks it volatile for archive scrub.
esphome_device_builder/controllers/_device_state_monitor.py Adds MAC normalization + apply path from mDNS TXT props.
esphome_device_builder/controllers/_device_scanner.py Threads mac_address metadata into device loading.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/controllers/_device_state_monitor.py Outdated
Comment thread esphome_device_builder/controllers/_device_state_monitor.py Outdated
Comment thread esphome_device_builder/helpers/mac_addresses.py Outdated
Comment thread esphome_device_builder/controllers/_device_state_monitor.py Outdated
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.62%. Comparing base (142957c) to head (bf3f3ab).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #338      +/-   ##
==========================================
+ Coverage   98.60%   98.62%   +0.01%     
==========================================
  Files          46       47       +1     
  Lines        5303     5373      +70     
==========================================
+ Hits         5229     5299      +70     
  Misses         74       74              
Flag Coverage Δ
py3.12 98.56% <100.00%> (+0.01%) ⬆️
py3.14 98.62% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...home_device_builder/controllers/_device_scanner.py 100.00% <100.00%> (ø)
...evice_builder/controllers/_device_state_monitor.py 98.68% <100.00%> (+0.06%) ⬆️
esphome_device_builder/controllers/config.py 97.24% <100.00%> (+0.05%) ⬆️
...e_device_builder/controllers/devices/controller.py 99.87% <100.00%> (+<0.01%) ⬆️
esphome_device_builder/helpers/device_yaml.py 99.61% <100.00%> (+<0.01%) ⬆️
esphome_device_builder/helpers/mac_addresses.py 100.00% <100.00%> (ø)
esphome_device_builder/models/devices.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

bdraco added 2 commits May 5, 2026 10:26
- ``MacAddressChangeCallback`` doc now describes the canonical
  form the callback receives (``XX:XX:XX:XX:XX:XX``, normalized
  by ``_normalize_mac``) instead of the pre-normalization
  "lowercase 12-hex-char" lie left over from the earlier shape.
- ``apply_mac_address`` doc spells out that ``_normalize_mac``
  uppercases + re-inserts colons, matching what the function
  actually does after the canonical-form flip in 9cdac68.
- ``_MAC_SEPARATORS`` comment no longer claims ``:`` is the
  firmware's wire format — today's broadcast is the compact
  12-hex-char form; ``:`` is the dashboard's *canonical* form
  applied at ingest. The strip-and-re-insert logic is independent
  of which form ESPHome happens to broadcast.
- ``derive_interface_macs`` validates hex on the trailing octet
  in addition to length, so a corrupt / hand-edited 17-char
  sidecar value with non-hex chars (``94:C9:60:1F:8C:ZZ``)
  returns empty derived MACs instead of raising ``ValueError``
  deep inside ``_offset_last_octet``. Regression test added.
When a ``.local`` device responds to ping but isn't broadcasting
``_esphomelib._tcp.local.`` (non-API ESPHome firmware, the
``zwave-proxy-seeedw5500.local`` case), the resolved address from
``dns_cache.async_resolve`` was being thrown away — the
``is_local_hostname`` guard in ``_ping_sweep`` skipped
``apply_ip`` "to keep mDNS in charge of IP tracking", but for
devices without an ``_esphomelib._tcp`` broadcast the mDNS path
never populates ``device.ip`` at all, leaving the drawer / table
to render an em-dash even after successful pings.

The guard's stated reason (avoid stale-DNS clobbering live mDNS
values) is moot because ``apply_ip`` already preserves an
existing multi-IP set when the incoming target is one of the
already-known addresses — the typical case for a ``.local``
device with an active broadcast. So the guard is unconditionally
removed, and ``test_ping_sweep_applies_ip_for_local_hosts``
inverts the previous "doesn't apply" assertion to pin the new
behaviour.
Three branches the diff added but the existing test set didn't
reach:

- ``_normalize_mac``'s ``except ValueError`` arm: covered by a
  12-char-after-separator-strip but non-hex input
  (``"94c9601f8cZZ"``). The length check passes, ``int(_, 16)``
  raises, and the helper returns empty so a corrupt TXT can't
  pollute the sidecar with a value that looks the right shape
  but doesn't decode.

- ``_apply_service_info``'s ``if mac := props.get("mac"):`` plumb
  to ``apply_mac_address``. Mirrors the existing
  ``test_apply_service_info_claims_online`` shape but populates
  ``decoded_properties = {"mac": "94c9601f8cf1"}`` and asserts
  the recording callback receives the normalized canonical form.

- ``_on_mac_address_change``'s "primary changed → derived
  re-derives" branch on a device that still has both ethernet
  and bluetooth integrations loaded. The previous
  ``clears_stale_derived`` test only exercised the
  integrations-removed path; this pins the inverse — when the
  integrations stay loaded, ``ethernet_mac`` and
  ``bluetooth_mac`` follow the new base via their respective
  offsets (a factory-replacement scenario where the same YAML
  gets pointed at a new physical board).

Codecov patch coverage on this PR was flagged at 94.44%
(_device_state_monitor.py with 4 missing lines); these tests
take it to 100%.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/helpers/mac_addresses.py Outdated
Comment thread esphome_device_builder/controllers/devices/controller.py
bdraco added 2 commits May 5, 2026 11:22
- ``derive_interface_macs`` docstring fully qualifies the
  ``_normalize_mac`` reference as
  ``controllers._device_state_monitor._normalize_mac``.
  ``mac_addresses.py`` doesn't import the function (only its
  output), so the bare-name cross-reference would dead-link in
  rendered docs.

- ``_resolve_device_metadata`` docstring updated to cover the
  new ``mac_address`` sidecar field and to note that
  ``ethernet_mac`` / ``bluetooth_mac`` are *not* persisted —
  they're recomputed at ``Device`` construction so a YAML edit
  toggling integrations picks up the new derivation on the next
  reload, no stale-cache window.
A monitor built without ``on_mac_address_change`` (older
in-process callers, narrower test fixtures) hits the
``return False`` branch at the top of ``apply_mac_address``
before the normalize / dedupe / dispatch path. Codecov was the
only thing flagging this; the new
``test_apply_mac_address_no_op_when_callback_unwired`` walks the
branch by constructing a monitor with only the required
``on_state_change`` / ``on_ip_change`` callbacks.

Patch coverage on PR #338 was at 98.61% with this single line
flagged; this takes it to 100%.
@bdraco bdraco merged commit 23e4bd8 into main May 5, 2026
12 of 13 checks passed
@bdraco bdraco deleted the mac-address-from-mdns branch May 5, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants