clang-tidy CI compiles with the raw esp8266-arduino-tidy env's flags — no
Python codegen, and it doesn't pick up the feature defines from defines.h in
the same way a real IDE does. USE_WIFI_IP_STATE_LISTENERS ends up undefined,
the derivation in mdns_component.h doesn't fire, and the class doesn't declare
start_polling_window_ / on_ip_state / the MDNS_POLL_* constants. Removing the
guards in the platform cpps made those definitions dangle.
Keep the listener-specific definitions under #ifdef USE_MDNS_EVENT_DRIVEN_POLLING.
The Python validator still enforces the runtime invariant (wifi or ethernet
must be present on ESP8266/RP2040), so production builds always fire through
the listener path; this is purely about what the tidy compiler sees.
Copilot flagged that defining these unconditionally in defines.h breaks static
analysis on platforms that don't meet the derivation's platform+listener guard:
USE_MDNS_WIFI_LISTENER enables 'public wifi::WiFiIPStateListener' inheritance in
the class declaration, but wifi_component.h is only #include'd inside the
mdns_component.h derivation block. On analysis envs where the derivation guard
is false (non-ESP8266/RP2040, or missing listener feature flags), the inherit
fires without the header in scope, causing 'use of undeclared identifier' and
related errors.
Letting the derivation in mdns_component.h be the sole source of truth keeps the
define and its corresponding include atomically linked.
The Python validator enforces that wifi or ethernet is configured on ESP8266/
RP2040, so USE_WIFI_IP_STATE_LISTENERS (or the ethernet equivalent) is always
requested in production, which always defines USE_MDNS_EVENT_DRIVEN_POLLING via
mdns_component.h's derivation. defines.h also declares it unconditionally for
static analysis. The #ifdef guards around start_polling_window_, setup()'s
listener subscription, and on_ip_state were dead code — a misconfiguration
should surface as a compile error, not silently strip out the method bodies.
Drops the mdns_pump_update() trampoline. It existed because start_polling_window_
lived in mdns_component.cpp which can't see the platform's MDNS global. Moving
start_polling_window_ into each platform cpp lets the set_interval lambda call
MDNS.update() directly — no forward decl, no ODR comment, no indirection.
- Drop redundant wifi_component.h / ethernet_component.h includes from the platform
.cpp files — mdns_component.h already pulls them in transitively under their
listener defines.
- Guard initialized_ with USE_RP2040 && USE_MDNS_EVENT_DRIVEN_POLLING instead of
USE_RP2040 alone, making the coupling with the listener-driven path explicit.
- Short comment on mdns_pump_update noting ODR is preserved by
FILTER_SOURCE_FILES compiling exactly one platform cpp per build.
- Inline comment on ESP8266 setup() noting AFTER_CONNECTION priority is why the
unconditional start_polling_window_() is safe.
- Collapse the disabled/platform early-return in _require_network_interface; drop
the redundant CORE.using_arduino check (ESP8266/RP2040 are always Arduino).
- Add USE_MDNS_EVENT_DRIVEN_POLLING and USE_MDNS_WIFI_LISTENER to defines.h for
static-analysis discoverability (ethernet listener is mutually exclusive with
wifi on these platforms, so one representative is enough).
Addresses Copilot review feedback: if someone enables mdns on ESP8266 or RP2040
without wifi (or ethernet on RP2040), the listener-based setup() is a no-op and
the user sees a silent failure rather than a helpful error.
FINAL_VALIDATE_SCHEMA rejects mdns on these platforms when no compatible
network component is present, naming the specific options that would satisfy
the requirement. The existing DEPENDENCIES = ["network"] covers most
misconfigurations indirectly, but an explicit network: alone (without wifi or
ethernet) slips past that check — now it fails with a clear message.
clang-tidy compiles mdns_esp8266.cpp / mdns_rp2040.cpp with only the tidy env's
raw build flags, without the Python codegen defines (USE_WIFI_IP_STATE_LISTENERS,
USE_ETHERNET_IP_STATE_LISTENERS, USE_MDNS_EVENT_DRIVEN_POLLING). Wrap all
listener-specific code paths under USE_MDNS_EVENT_DRIVEN_POLLING so tidy sees a
compilable translation unit with an empty setup() instead of unresolved members
on WiFiComponent / MDNSComponent.
Production builds always have the listener defines via the mdns Python
to_code()'s wifi.request_wifi_ip_state_listener() /
ethernet.request_ethernet_ip_state_listener() calls, so this is tidy-only dead
code at runtime.
mDNS on ESP8266/RP2040 always runs over a network interface (WiFi on ESP8266;
WiFi or W5500/etc. ethernet on RP2040), and every such interface already
publishes an IP state listener in tree. USE_MDNS_EVENT_DRIVEN_POLLING is
therefore always defined in production, so the fallback set_interval() paths
in both platform files and the was_connected_ bookkeeping are dead code.
Also drop the ethernet-specific test — the existing wifi and ethernet+mdns
combos are already covered by the mdns test fixtures paired with their network
component tests.
Trim redundant comments throughout: the header now documents the ~9s
probe/announce window and why update() can stop afterward in a few lines
instead of a full essay; platform files keep only the non-obvious bits (why
RP2040 needs to drive begin()/notifyAPChange() itself, why re-arming on any
listener notification is correct).
Extends the WiFi-only listener pattern from the previous commit to also subscribe
to EthernetIPStateListener when Ethernet is configured. RP2040 can run mDNS over a
W5500 ethernet shield without WiFi, and mDNS and WiFi are mutually exclusive on
RP2040 (the framework doesn't support both simultaneously on the CYW43/PIO paths),
so this adds the ethernet-only path without touching the WiFi path.
ESPHome's wifi and ethernet components already publish compatible IP state listener
APIs (`WiFiIPStateListener::on_ip_state` and `EthernetIPStateListener::on_ip_state`
with identical signatures). MDNSComponent multiply-inherits both when available; a
single on_ip_state() override satisfies both vtable entries.
- New `USE_MDNS_WIFI_LISTENER` / `USE_MDNS_ETHERNET_LISTENER` gates control per-
interface subscription. `USE_MDNS_EVENT_DRIVEN_POLLING` fires if either is
available.
- Python side now calls `ethernet.request_ethernet_ip_state_listener()` when
ethernet is in the config (RP2040 only — ESP8266 has no ethernet driver).
- setup() seeds current state for each registered listener so an already-up
interface still triggers MDNS.begin() + polling window under AFTER_CONNECTION
priority.
Tests: adds `test-enabled-ethernet.rp2040-ard.yaml` covering the ethernet-only
path. Existing `test-enabled.rp2040-ard.yaml` (WiFi-only) and ESP8266 tests
continue to pass.
clang-tidy CI compiles the source with the esp8266-arduino-tidy env's raw build
flags (-DUSE_ESP8266 only) without running the Python codegen that adds
USE_WIFI_IP_STATE_LISTENERS. The previous guard assumed USE_WIFI_IP_STATE_LISTENERS
would always be defined on ESP8266, so clang-tidy failed with 'no member named
add_ip_state_listener in wifi::WiFiComponent'.
Gate USE_MDNS_EVENT_DRIVEN_POLLING on USE_WIFI + USE_WIFI_IP_STATE_LISTENERS for
both ESP8266 and RP2040. When either is absent, fall back to the pre-PR behaviour:
set_interval(MDNS_UPDATE_INTERVAL_MS, MDNS.update) running forever. Python side
already only requests the listener slot when WiFi is in the config, so real
production builds on ESP8266 (which always have WiFi) continue to use the
event-driven path — only the clang-tidy static-analysis build takes the fallback.
ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events), not
on IP loss — on disconnect, only the WiFiConnectStateListener's disconnect path
fires (see wifi_component_esp8266.cpp:952-962 and wifi_component_pico_w.cpp:340).
The previous commit's `ip_was_up_` transition tracking was broken: after the first
IP-up event, `ip_was_up_` latched to true and never reset, so subsequent
disconnect+reconnect cycles would see has_ip=true && ip_was_up_=true and skip
re-arming the polling window.
Fix: always re-arm on any IP notification. The scheduler's set_interval/set_timeout
with a uint32_t ID already performs atomic cancel-and-add for matching IDs
(Scheduler::set_timer_common_ line 232-234), so start_polling_window_ is idempotent
and needs no explicit cancel. Drop the ip_was_up_ field and cancel_polling_window_
helper entirely.
The !has_ip branch (cancel on disconnect) was dead code: it would never fire because
the listener doesn't receive disconnect events. Removing it; the polling window will
naturally expire on its own (at most 12s of harmless MDNS.update() calls during a
disconnect that isn't followed by reconnect within the window).
The Arduino LEAmDNS library only has meaningful timer-driven work during the
~9 s probe+announce phase following MDNS.begin() or _restart(): 3 probes at
250 ms + 8 announcements at 1000 ms, then all internal timeouts are set to
resetToNeverExpires(). Incoming packets are handled via the lwIP UDP RX
callback independently of update(). ESPHome does not issue service queries,
so the query cache path is always a no-op.
The previous implementation ran set_interval(50) forever — ~20 dispatches/sec,
1200+ scheduler calls per minute of pure overhead once probing completed.
This PR arms a bounded MDNS_POLL_WINDOW_MS (12 s) polling window driven by
WiFiIPStateListener events. A fresh window covers each probe/announce cycle
(boot, wifi reconnect, or internal _restart() triggered by netif changes);
outside the window there are zero scheduler dispatches and the scheduler
heap contains no mDNS items.
ESP8266 is WiFi-only in the Arduino build so the path is unconditional.
RP2040 supports W5500 ethernet without WiFi, so the listener is requested
only when WiFi is in the config; ethernet-only RP2040 builds keep the
legacy polling loop.
Scheduler IDs use uint32_t (MDNS_POLL_ID / MDNS_POLL_STOP_ID) to avoid the
name-hash/strcmp cost of string-named timers on the cancel + re-arm paths.
In time_64.cpp's true-rollover branch, bump the just-loaded `major`
local first and __atomic_store_n that value to millis_major, instead
of reading the global again for the store expression. Equivalent
under the held lock; clearer and avoids a second read.
scheduler.h indent: the NO_ATOMICS #else branch bodies read at 2
spaces while the sibling #ifdef/#elif branches read at 4. clang-format
refuses to normalise these consistently — every manual re-indent to 4
spaces gets reverted by the hook. Leaving as clang-format produces it.
Two fixes Copilot flagged on #15947:
1. Use __atomic_load_n to re-read last_millis under the lock, not a
plain read. The forward-progression branch (else if) writes
last_millis with __atomic_store_n without holding lock, so the
under-lock plain read would race with it and be UB in the C++
memory model.
2. Reload major from millis_major after acquiring the lock. The
unlocked load at the top of the function can be stale by the time
we get the lock: another thread may have completed a rollover
between the unlocked load and the lock acquisition, leaving our
local major behind by one. Without reload the function could
return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7
days). The MULTI_ATOMICS branch already handles this via its retry
loop; NO_ATOMICS just reloads under the lock.
Two fixes Copilot flagged on #15947:
1. Use __atomic_load_n to re-read last_millis under the lock, not a
plain read. The forward-progression branch (else if) writes
last_millis with __atomic_store_n without holding lock, so the
under-lock plain read would race with it and be UB in the C++
memory model.
2. Reload major from millis_major after acquiring the lock. The
unlocked load at the top of the function can be stale by the time
we get the lock: another thread may have completed a rollover
between the unlocked load and the lock acquisition, leaving our
local major behind by one. Without reload the function could
return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7
days). The MULTI_ATOMICS branch already handles this via its retry
loop; NO_ATOMICS just reloads under the lock.
Switch writer-side plain stores under lock_ to __atomic_store_n with
__ATOMIC_RELAXED on NO_ATOMICS. The input value for RMW is read
plainly (safe — only writers mutate, serialised by the lock; readers
only atomic-load so two reads don't race). Closes the formal C++
memory-model hole where plain-store vs atomic-load was a data race
in the standard even though aligned 32-bit STR/LDR on ARMv5TE is
atomic in practice.
Applies to scheduler.h counter mutators and the under-lock writes
to last_millis / millis_major in time_64.cpp's near-rollover branch.
Same ARMv5TE codegen (plain STR). ATOMICS / SINGLE paths unchanged.
Use `#if defined(X)` / `#elif defined(Y)` / `#else` for the three-way
ATOMICS / NO_ATOMICS / SINGLE split. Also fix the SINGLE-branch body
indentation to match the other branches.
The preceding commit needlessly rewrote comments that were still
accurate. Revert the prose-only changes; keep only the two line-level
code changes (__atomic_load_n on the unlocked reads, __atomic_store_n
on the unlocked write).
Same treatment as the scheduler counters (#15947):
- Unlocked reads of millis_major / last_millis at the top of
Millis64Impl::compute(): switch from plain reads to
__atomic_load_n(&..., __ATOMIC_RELAXED).
- Unlocked write of last_millis in the "normal forward progression"
branch: switch from plain assignment to __atomic_store_n(...,
__ATOMIC_RELAXED). This is the one write that happens without the
lock, so it needs to be formally atomic to pair cleanly with the
unlocked atomic reader in the C++ memory model.
- Writes under `lock` stay plain (millis_major++, last_millis = now
inside the near-rollover branch). The lock serialises them against
other writers.
On ARMv5TE the builtins compile to plain LDR/STR — same codegen, no
libatomic dependency. Updates the "accepting minor races" comment to
describe the formally-defined version of the race.
Walk back the __atomic_store_n on the writer paths — the mutators
already hold lock_, so plain counter_++/=/+=/-- is sufficient to
serialise against other writers. The reader fast-path still uses
__atomic_load_n(&counter, __ATOMIC_RELAXED) to express concurrent-
read intent in the C++ memory model and keep the compiler from
caching/eliding the read. On ARMv5TE it compiles to a plain LDR —
same codegen as before.
Copilot review on #15947 flagged that `volatile uint32_t` is not a
well-defined concurrent access in the C++ memory model — it prevents
the compiler caching/eliding the read, but does not turn a plain
cross-thread read/write pair into a defined access. Technically still
a formal data race even though aligned 32-bit LDR/STR on ARMv5TE is
atomic at the hardware level.
Switch the NO_ATOMICS counter reads and writes to GCC's atomic
builtins with __ATOMIC_RELAXED:
- Readers: __atomic_load_n(&counter, __ATOMIC_RELAXED)
- Writers (under lock_): __atomic_store_n(&counter, new_value,
__ATOMIC_RELAXED)
- Increment/decrement (under lock_): explicit load + compute +
__atomic_store_n. Lock_ serialises the load-modify-store against
other writers; the atomic ops make the write visible to concurrent
readers in the memory model.
On ARMv5TE these builtins compile to plain LDR/STR — same codegen as
the previous volatile approach, and no libatomic dependency (only RMW
builtins like __atomic_fetch_add would need the lib). ATOMICS and
SINGLE paths are unchanged.
Rename the seven counter RMW mutators to carry the `_locked_` suffix
that matches the existing convention (pop_raw_locked_,
is_item_removed_locked_, cancel_item_locked_, etc.):
to_add_count_increment_ -> to_add_count_increment_locked_
to_add_count_clear_ -> to_add_count_clear_locked_
defer_count_increment_ -> defer_count_increment_locked_
defer_count_clear_ -> defer_count_clear_locked_
to_remove_add_ -> to_remove_add_locked_
to_remove_decrement_ -> to_remove_decrement_locked_
to_remove_clear_ -> to_remove_clear_locked_
The caller-must-hold-lock contract became load-bearing when the
underlying counters became volatile on NO_ATOMICS: ++/+=/-- compile to
a three-instruction LDR/OP/STR sequence that is not atomic against a
concurrent RMW from another task, so the lock is what keeps the
counter consistent. The new suffix makes the requirement explicit at
every call site, matching how the rest of the scheduler documents the
same invariant.
No behavioural change; all call sites already hold lock_.
The _empty_() helpers (to_add_empty_, defer_empty_, to_remove_empty_)
forced the lock path on ESPHOME_THREAD_MULTI_NO_ATOMICS by hardcoding
`return false`. That made Scheduler::call() pay a FreeRTOS mutex
round-trip for each of process_defer_queue_ / process_to_add /
cleanup_ on every idle tick just to confirm "nothing to do".
On the only NO_ATOMICS target (BK72xx — ARMv5TE, single-core), an
aligned 32-bit load is atomic at the hardware level. Mark the three
skip-work counters volatile so the compiler cannot cache or elide the
read, and let _empty_() compare against zero directly. Writers still
hold lock_ for any RMW — that invariant is unchanged.
A stale 0 is benign: the counter is checked on every Scheduler::call()
iteration, so a missed update is caught next tick. Same pattern as the
NO_ATOMICS reads in time_64.cpp.
On BK72xx at ~3100 iter/min with ~8us/mutex this reclaims roughly
75ms/min of main-loop overhead. Measured on BK7238/BK7231N while
profiling alongside libretiny-eu/libretiny#360.
ATOMICS and SINGLE paths are unchanged (SINGLE keeps plain uint32_t,
no volatile-read overhead).