Skip to content

Commit 29529fc

Browse files
committed
Clear stale addresses on retarget and stop loopback Online latches
Removing or changing wifi.use_address left the ping sweep pinging the RAM addresses resolved from the old value, latching the device Online off whatever host answered there. An address change now drops the resolved set (unless mDNS/MQTT owns the name) before waking the sweep. Loopback joins unspecified as an unusable address across the liveness paths, and an out-of-band edit that moves a network block schedules a StorageJSON regenerate so the effective address tracks the YAML.
1 parent 3116961 commit 29529fc

20 files changed

Lines changed: 417 additions & 48 deletions

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,27 @@ against legacy behaviour before assuming the simpler version suffices.
624624
has no browser `Removed` counterpart, so it locks out `should_ping` and
625625
latches the device ONLINE forever (#1776). The `ping`-source result
626626
(priority 1) stays sweep-eligible so a dead entry demotes.
627+
- **Configured-address changes drop the ping-learned RAM addresses**
628+
(`DeviceStateMonitor.address_retargeted`, #2486). The sweep stores
629+
what it resolved from `device.address` into
630+
`runtime_state.ip_addresses` and falls back to that set when the
631+
address stops resolving — so a `wifi.use_address` edit/removal
632+
would keep pinging whatever answered at the *old* address and
633+
re-latch ONLINE forever. The scan-change address branch clears the
634+
resolved set (skipped while mDNS/MQTT owns the name — their
635+
evidence is identity-carrying and address-independent) before
636+
waking the sweep. Relatedly, loopback counts as unusable wherever
637+
unspecified does (`helpers/ip.py`), and `_resolve_and_ping` filters
638+
its final target list, so `use_address: 127.0.0.1` applies OFFLINE
639+
instead of latching ONLINE off the dashboard host — while the DNS
640+
cache still returns literal loopback verbatim, keeping SSH-tunnel
641+
OTA workflows working. Out-of-band edits (git pull, external
642+
editor) that move a top-level network, `substitutions:`, or
643+
`packages:` block schedule a StorageJSON regen via the off-wire
644+
`Device.network_fingerprint` change detector (API-path writes
645+
already regen on every save). An edit to a referenced package
646+
*file* stays uncovered — it changes no device YAML, so no scan
647+
event fires.
627648
- **`_http._tcp` identity fallback** (`MdnsSource._on_http_service_state_change`,
628649
for a configured device without `api:`). Such a device never publishes
629650
`_esphomelib._tcp` (behind `USE_API`); its broadcast is the `_http._tcp`

esphome_device_builder/controllers/_device_mqtt_monitor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,8 +662,8 @@ def _extract_ip(data: dict[str, Any]) -> str:
662662
Pull the first usable IP field from a discovery payload.
663663
664664
ESPHome devices expose their addresses as ``ip``, ``ip0``, ``ip1``,
665-
... — returns the first value that parses as a real, non-unspecified
666-
IP (the payload is untrusted), or empty string when none qualify.
665+
... — returns the first value that parses as a real, usable IP
666+
(the payload is untrusted), or empty string when none qualify.
667667
"""
668668
for key in ("ip", "ip0", "ip1", "ip2"):
669669
value = data.get(key)

esphome_device_builder/controllers/_device_state_monitor/controller.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from typing import Any, Protocol
3030

3131
from ...helpers.async_ import create_eager_task, drain_tasks, log_task_exit
32-
from ...helpers.ip import drop_unspecified_addresses, is_unspecified_address
32+
from ...helpers.ip import drop_unusable_addresses, is_unusable_address
3333
from ...helpers.subscriber_presence import SubscriberPresence
3434
from ...models import (
3535
RUNTIME_STATE_FIELD_NAMES,
@@ -50,7 +50,7 @@
5050
from .importable import ImportableDiscovery
5151
from .mdns import MdnsSource
5252
from .ping import PingSource
53-
from .shared import _SOURCE_PRIORITY, should_ping
53+
from .shared import _SOURCE_PRIORITY, identity_source_owns, should_ping
5454

5555
_LOGGER = logging.getLogger(__name__)
5656
# Cap on draining the ping / API-info / resolve tasks at shutdown.
@@ -412,7 +412,7 @@ def apply_ip(self, name: str, ip: str) -> bool:
412412
"""
413413
if not ip:
414414
raise ValueError("empty ip; use clear_resolved_addresses")
415-
if is_unspecified_address(ip):
415+
if is_unusable_address(ip):
416416
return False
417417
devices = self._get_devices_by_name(name)
418418
if not devices:
@@ -435,7 +435,7 @@ def apply_ip_addresses(self, name: str, addresses: list[str]) -> bool:
435435
"""
436436
if not addresses:
437437
raise ValueError("empty addresses; use clear_resolved_addresses")
438-
usable = drop_unspecified_addresses(addresses)
438+
usable = drop_unusable_addresses(addresses)
439439
if not usable:
440440
return False
441441
return self._dispatch_ip(name, _pick_ipv4(usable), usable)
@@ -634,6 +634,14 @@ def invalidate_persisted_ip(self, name: str, stale_ip: str) -> None:
634634
if self._on_persisted_ip_invalidated is not None:
635635
self._on_persisted_ip_invalidated(name, stale_ip)
636636

637+
def address_retargeted(self, name: str) -> None:
638+
"""Drop stale resolved IPs and wake the sweep after *name*'s configured address changed."""
639+
# mDNS / MQTT evidence is identity-carrying and address-
640+
# independent, so their resolved set survives the retarget.
641+
if not identity_source_owns(self, name):
642+
self.clear_resolved_addresses(name)
643+
self.probe_device_ping(name)
644+
637645
def probe_device_ping(self, device_name: str) -> None:
638646
"""
639647
Wake the ICMP sweep loop when *device_name* warrants a probe.

esphome_device_builder/controllers/_device_state_monitor/mdns.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
from ...helpers.async_ import drain_tasks, log_task_exit
3333
from ...helpers.hostname import normalize_hostname
34-
from ...helpers.ip import drop_unspecified_addresses
34+
from ...helpers.ip import drop_unusable_addresses
3535
from ...models import DeviceState
3636
from .._reachability_tracker import MdnsCacheInfo
3737
from .helpers import (
@@ -304,7 +304,7 @@ def get_cached_addresses(self, host_name: str) -> list[str] | None:
304304
if not info.load_from_cache(self._zeroconf.zeroconf):
305305
return None
306306
addresses = info.parsed_scoped_addresses(IPVersion.All)
307-
return drop_unspecified_addresses(addresses) or None
307+
return drop_unusable_addresses(addresses) or None
308308

309309
def reconcile_from_cache(self, device_name: str) -> None:
310310
"""
@@ -542,7 +542,7 @@ def _apply_service_info(self, device_name: str, info: AsyncServiceInfo) -> None:
542542
announce never claims.
543543
"""
544544
monitor = self._monitor
545-
# Claimed before the apply-path unspecified-address filter, unlike
545+
# Claimed before the apply-path unusable-address filter, unlike
546546
# the active-resolve path: a resolved service is liveness evidence
547547
# on its own (already claimed even when addressless), and the
548548
# browser's ``Removed`` lifecycle withdraws the claim so ping

esphome_device_builder/controllers/_device_state_monitor/ping.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from icmplib.exceptions import ICMPLibError
1111

1212
from ...helpers.hostname import is_local_hostname
13+
from ...helpers.ip import drop_unusable_addresses
1314
from ...models import Device, DeviceState
1415
from . import shared
1516
from ._sweep_source import SweepSource
@@ -230,6 +231,11 @@ async def _resolve_and_ping(self, device: Device) -> None:
230231
# prior MQTT/DNS observation left a usable IP. Ping that so
231232
# ping can confirm a device the network won't resolve.
232233
addresses = list(device.runtime_state.ip_addresses)
234+
# A literal loopback ``use_address`` rides the DNS cache's
235+
# literal short-circuit past every apply-side filter; pinging
236+
# it would latch the device ONLINE off the dashboard host
237+
# itself (#2486).
238+
addresses = drop_unusable_addresses(addresses)
233239
if not addresses:
234240
shared.apply_ping_result(monitor, device.name, None)
235241
return

esphome_device_builder/controllers/_device_state_monitor/shared.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import TYPE_CHECKING
1313

1414
from ...helpers.hostname import is_local_hostname
15-
from ...helpers.ip import drop_unspecified_addresses
15+
from ...helpers.ip import drop_unusable_addresses
1616
from ...models import Device, DeviceState, ReachabilitySource
1717

1818
if TYPE_CHECKING:
@@ -56,8 +56,12 @@ def should_ping(monitor: DeviceStateMonitor, device: Device) -> bool:
5656
"""
5757
if device.runtime_state.state != DeviceState.ONLINE:
5858
return True
59-
source = monitor.state.state_source.get(device.name, ReachabilitySource.UNKNOWN)
60-
return _SOURCE_PRIORITY.get(source, 0) <= _SOURCE_PRIORITY[ReachabilitySource.PING]
59+
return not identity_source_owns(monitor, device.name)
60+
61+
62+
def identity_source_owns(monitor: DeviceStateMonitor, name: str) -> bool:
63+
"""Return True when a higher-than-ping source (mDNS / MQTT) owns *name*."""
64+
return _SOURCE_PRIORITY[monitor.priority_for(name)] > _SOURCE_PRIORITY[ReachabilitySource.PING]
6165

6266

6367
def apply_ping_result(monitor: DeviceStateMonitor, name: str, rtt_ms: float | None) -> None:
@@ -103,9 +107,9 @@ def apply_resolved_addresses(
103107
"""
104108
if not isinstance(addresses, list):
105109
return
106-
# Filter before the ONLINE claim — an all-unspecified answer must
110+
# Filter before the ONLINE claim — an all-unusable answer must
107111
# not latch the device ONLINE while the apply refuses the IPs.
108-
usable = drop_unspecified_addresses(addresses)
112+
usable = drop_unusable_addresses(addresses)
109113
if not usable:
110114
return
111115
# The claim rides the live anchor PTR, whose ``Removed``

esphome_device_builder/controllers/_dns_cache.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from icmplib import NameLookupError, async_resolve
2727

2828
from ..helpers.hostname import normalize_hostname
29-
from ..helpers.ip import drop_unspecified_addresses
29+
from ..helpers.ip import drop_unusable_addresses
3030

3131
_DEFAULT_TTL_SECONDS = 120
3232
_RESOLVE_TIMEOUT_SECONDS = 3.0
@@ -147,6 +147,7 @@ async def _try_resolve(hostname: str) -> list[str] | None:
147147
addresses = cast("list[str]", await async_resolve(hostname))
148148
except _RESOLVE_EXCEPTIONS:
149149
return None
150-
# A sinkhole resolver answers with 0.0.0.0 / ::; an
151-
# all-unspecified reply is a failed lookup, not a result.
152-
return drop_unspecified_addresses(addresses) or None
150+
# A sinkhole resolver answers with 0.0.0.0 / :: (or 127.0.0.1,
151+
# Pi-hole style); an all-unusable reply is a failed lookup, not
152+
# a result.
153+
return drop_unusable_addresses(addresses) or None

esphome_device_builder/controllers/devices/metadata.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ...helpers.build_size import coerce_sidecar_int
1212
from ...helpers.config_hash import read_build_info_hash
1313
from ...helpers.device_yaml import parse_platform_from_yaml
14-
from ...helpers.ip import is_unspecified_address
14+
from ...helpers.ip import is_unusable_address
1515
from .._device_builder_base import DeviceBuilderBase
1616
from .._device_scanner import DeviceFileMetadata
1717
from ..config import metadata_transaction
@@ -97,7 +97,7 @@ def _resolve_device_metadata(self, config_dir: Path, filename: str) -> DeviceFil
9797
store_md = self._metadata_store.get(filename)
9898
shared_md = self._shared_sidecar.get_sync(filename)
9999
ip = str(store_md.get("ip", ""))
100-
if ip and is_unspecified_address(ip):
100+
if ip and is_unusable_address(ip):
101101
# A persisted sidecar can hold a poisoned IP the runtime
102102
# filters never saw.
103103
ip = ""

esphome_device_builder/controllers/devices/scan_change.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,27 @@ def on_scan_change(
5555
):
5656
# The change swapped in a new address (a ``wifi.use_address``
5757
# edit, or the post-regen StorageJSON replacing the
58-
# ``<file>.local`` fallback); without the wake the new address
59-
# waits out the remainder of the periodic sweep interval.
60-
controller._state_monitor.probe_device_ping(device.name)
58+
# ``<file>.local`` fallback); without the retarget the sweep
59+
# keeps pinging addresses resolved from the old one (#2486) or
60+
# waits out the remainder of the periodic interval.
61+
controller._state_monitor.address_retargeted(device.name)
6162
if kind in (ScanChange.UPDATED, ScanChange.RELOADED, ScanChange.REMOVED):
6263
# YAML cache key changed (or a reload re-read it); clear any
6364
# prior failure marker so the next edit gets a fresh chance at
6465
# ``--only-generate`` (and re-creating a deleted file
6566
# later doesn't inherit the old failure).
6667
controller.state.regenerate_failed.discard(device.configuration)
68+
if (
69+
kind is ScanChange.UPDATED
70+
and previous is not None
71+
and previous.network_fingerprint != device.network_fingerprint
72+
):
73+
# An out-of-band edit (git pull, external editor) moved a
74+
# network block; without a regen ``StorageJSON.address`` keeps
75+
# the old ``use_address`` and the sweep pings a stale host
76+
# (#2486). API-path writes regen unconditionally in
77+
# ``_persist_yaml_mutation`` and surface here as RELOADED.
78+
controller._schedule_storage_regenerate(device.configuration)
6779
# First-sight devices with no compile output carry the
6880
# ``<filename>.local`` address fallback and an empty
6981
# ``loaded_integrations`` list. Schedule a background

esphome_device_builder/helpers/device_yaml/_loading.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
extract_esphome_meta_from_config,
3030
extract_logger_baud_rate,
3131
extract_logger_interface,
32+
extract_network_address_fingerprint,
3233
extract_ota_partition_access,
3334
get_api_encryption_block,
3435
has_top_level_block,
@@ -318,6 +319,7 @@ def load_device_from_storage(
318319
# this on the next compile if the device picks a different
319320
# ``esphome.address``.
320321
address=(storage.address if storage and storage.address else f"{fallback_name}.local"),
322+
network_fingerprint=extract_network_address_fingerprint(yaml_content),
321323
ip=ip,
322324
web_port=storage.web_port if storage else None,
323325
current_version=const.__version__,

0 commit comments

Comments
 (0)