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
2 changes: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ Connections that arrive on the trusted ingress site (HA add-on supervisor proxy)
| `devices/ignore` | `{name, ignore?}` | — | Toggle device visibility |
| `devices/validate` | `{configuration}` | Streaming | Validate YAML config |
| `devices/logs` | `{configuration, port?: "OTA" \| serial, no_states?: bool}` | Streaming | Stream live device logs. `port` defaults to `"OTA"` (empty string is treated the same) — without a default, `esphome logs` falls into an interactive port-choice prompt when multiple targets are visible and the stdin-less subprocess crashes with `EOFError`. When `port` resolves to `"OTA"` the dashboard forwards its mDNS / DNS cache as `--mdns-address-cache` / `--dns-address-cache` so the CLI doesn't redo resolution the dashboard already has (legacy-dashboard parity with `build_cache_arguments`). |
| `devices/subscribe_reachability` | `{device_name}` | Streaming (`reachability_state`) | Drawer-only per-device reachability stream. Each `reachability_state` event carries the per-signal freshness snapshot (`mdns_last_seen_seconds_ago`, `mdns_ttl_remaining_seconds`, `mdns_ptr_ttl_remaining_seconds`, `mdns_txt_records`, `ping_last_seen_seconds_ago`, `ping_rtt_ms`, `mqtt_last_seen_seconds_ago`, plus `state` / `active_source` / `ip`). `mdns_ptr_ttl_remaining_seconds` is the PTR record's own remaining TTL — the countdown to the zeroconf `Removed` event that flips an mDNS-owned device OFFLINE (`null` when no PTR is cached). Pair with `devices/stop_stream`. |
| `devices/get_reachability` | `{device_name}` | `DeviceReachabilityData` | One-shot read of the same snapshot the subscribe stream seeds, including the live `mdns_ptr_ttl_remaining_seconds`. Exists because a pure PTR TTL refresh is deduped by zeroconf and never pushed, so a client that needs the current value must read it. `INVALID_MESSAGE` when `device_name` is empty; `NOT_FOUND` when no configured device matches. |

`Device.state`: `DeviceState` — `unknown`, `online`, or `offline` (discovered via mDNS + ping).
`Device.has_pending_changes`: `true` = config changed since last compile, `false` = up to date, `null` = never compiled.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,18 @@ def get_mdns_cache_info(self, name: str) -> MdnsCacheInfo | None:
# into 0.108 and render as "TTL: 0s".
age_s = max(0.0, millis_to_seconds(now_ms - latest.created))
ttl_remaining_s = max(0.0, float(latest.get_remaining_ttl(now_ms)))
# The PTR's own TTL — what ``AsyncServiceBrowser`` counts
# down to fire ``Removed`` (OFFLINE). Surfaced separately so
# the drawer can show a "goes offline in N" countdown; the
# union TTL above tracks the freshest record (usually the
# ~120s A the refresh loop renews), not the offline horizon.
ptr_ttl_remaining_s = (
max(0.0, float(ptr.get_remaining_ttl(now_ms))) if ptr is not None else None
)
return MdnsCacheInfo(
age_seconds=age_s,
ttl_remaining_seconds=ttl_remaining_s,
ptr_ttl_remaining_seconds=ptr_ttl_remaining_s,
txt_records=_decode_mdns_txt_records(txt_dns_records),
)

Expand Down
10 changes: 10 additions & 0 deletions esphome_device_builder/controllers/_reachability_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class MdnsCacheInfo:
refresh counts.
``ttl_remaining_seconds`` = the same record's
:meth:`DNSRecord.get_remaining_ttl`.
``ptr_ttl_remaining_seconds`` = the PTR record's own
remaining TTL, i.e. seconds until ``AsyncServiceBrowser``
fires ``Removed`` and the device flips OFFLINE; ``None``
when no PTR is cached. Distinct from the union TTL above,
which tracks whichever record is freshest (usually the
~120s A record the refresh loop renews).
``txt_records`` = parsed ``key -> value`` pairs from the
device's TXT record, sorted alphabetically for
deterministic wire output.
Expand All @@ -66,6 +72,7 @@ class MdnsCacheInfo:

age_seconds: float
ttl_remaining_seconds: float
ptr_ttl_remaining_seconds: float | None = None
txt_records: dict[str, str] = field(default_factory=dict)


Expand Down Expand Up @@ -171,6 +178,7 @@ def _ago(timestamp: float | None) -> float | None:

mdns_age: float | None = None
mdns_ttl_remaining: float | None = None
mdns_ptr_ttl_remaining: float | None = None
# ``None`` means "hide the TXT section" — collapses
# both "no TXT cached" and "TXT cached but no useful
# keys decoded" so the renderer is a single
Expand All @@ -181,6 +189,7 @@ def _ago(timestamp: float | None) -> float | None:
if info is not None:
mdns_age = info.age_seconds
mdns_ttl_remaining = info.ttl_remaining_seconds
mdns_ptr_ttl_remaining = info.ptr_ttl_remaining_seconds
# Fresh dict on the wire so downstream mutation
# can't reach into zeroconf's internals.
mdns_txt_records = dict(info.txt_records) if info.txt_records else None
Expand All @@ -192,6 +201,7 @@ def _ago(timestamp: float | None) -> float | None:
"ip": ip,
"mdns_last_seen_seconds_ago": mdns_age,
"mdns_ttl_remaining_seconds": mdns_ttl_remaining,
"mdns_ptr_ttl_remaining_seconds": mdns_ptr_ttl_remaining,
"mdns_txt_records": mdns_txt_records,
"ping_last_seen_seconds_ago": _ago(self._ping_last_seen.get(name)),
"mqtt_last_seen_seconds_ago": _ago(self._mqtt_last_seen.get(name)),
Expand Down
20 changes: 20 additions & 0 deletions esphome_device_builder/controllers/devices/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,26 @@ async def subscribe_reachability(
self, device_name=device_name, client=client, message_id=message_id
)

@api_command("devices/get_reachability")
async def get_reachability(
self,
*,
device_name: str,
**kwargs: Any,
) -> DeviceReachabilityData:
"""
One-shot read of the reachability snapshot.

``INVALID_MESSAGE`` when *device_name* is empty, ``NOT_FOUND``
when no configured device matches.
"""
if not device_name:
raise CommandError(ErrorCode.INVALID_MESSAGE, "device_name is required")
snapshot = self.get_reachability_snapshot(device_name)
if snapshot is None:
raise CommandError(ErrorCode.NOT_FOUND, f"No configured device named {device_name!r}")
return snapshot

async def _reachability_refresh_loop(self, device_name: str) -> None:
await reachability.refresh_loop(self, device_name)

Expand Down
1 change: 1 addition & 0 deletions esphome_device_builder/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ class DeviceReachabilityData(TypedDict):
ip: str
mdns_last_seen_seconds_ago: float | None
mdns_ttl_remaining_seconds: float | None
mdns_ptr_ttl_remaining_seconds: float | None
mdns_txt_records: dict[str, str] | None
ping_last_seen_seconds_ago: float | None
mqtt_last_seen_seconds_ago: float | None
Expand Down
48 changes: 48 additions & 0 deletions tests/controllers/devices/test_subscribe_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,51 @@ async def fast_sleep(_: float) -> None:
await controller._reachability_refresh_loop("kitchen")

state_monitor.refresh_mdns.assert_not_awaited()


async def test_get_reachability_returns_current_snapshot(
tmp_path: Path, make_controller: MakeControllerFactory
) -> None:
"""The one-shot poll command returns the same snapshot subscribe seeds."""
controller = make_controller(tmp_path)
tracker = ReachabilityTracker()
bus = EventBus()
_wire_reachability(controller, tracker, bus)
_seed_device(controller)
tracker.observe("kitchen", "ping")

snap = await controller.get_reachability(device_name="kitchen")
assert snap["device"] == "kitchen"
assert snap["ping_last_seen_seconds_ago"] is not None
# Same wire shape as the subscribe seed (PTR-TTL field included).
assert snap.keys() == controller.get_reachability_snapshot("kitchen").keys()
assert "mdns_ptr_ttl_remaining_seconds" in snap


async def test_get_reachability_unknown_device_raises_not_found(
tmp_path: Path, make_controller: MakeControllerFactory
) -> None:
"""Unknown ``device_name`` surfaces as a typed NOT_FOUND."""
controller = make_controller(tmp_path)
tracker = ReachabilityTracker()
bus = EventBus()
_wire_reachability(controller, tracker, bus)
controller._scanner.get_by_name = lambda _name: []

with pytest.raises(CommandError) as exc:
await controller.get_reachability(device_name="nope")
assert exc.value.code == ErrorCode.NOT_FOUND


async def test_get_reachability_missing_device_name_raises(
tmp_path: Path, make_controller: MakeControllerFactory
) -> None:
"""Empty ``device_name`` surfaces as a typed INVALID_MESSAGE, mirroring subscribe."""
controller = make_controller(tmp_path)
tracker = ReachabilityTracker()
bus = EventBus()
_wire_reachability(controller, tracker, bus)

with pytest.raises(CommandError) as exc:
await controller.get_reachability(device_name="")
assert exc.value.code == ErrorCode.INVALID_MESSAGE
11 changes: 10 additions & 1 deletion tests/test_reachability_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def test_snapshot_empty_returns_all_nulls() -> None:
"ip": "",
"mdns_last_seen_seconds_ago": None,
"mdns_ttl_remaining_seconds": None,
"mdns_ptr_ttl_remaining_seconds": None,
"mdns_txt_records": None,
"ping_last_seen_seconds_ago": None,
"mqtt_last_seen_seconds_ago": None,
Expand All @@ -69,14 +70,21 @@ def test_snapshot_uses_mdns_cache_reader() -> None:
TTL refreshes — stamping at the call site would lie). The
snapshot reads truth from the cache reader.
"""
info = MdnsCacheInfo(age_seconds=12.4, ttl_remaining_seconds=107.6)
info = MdnsCacheInfo(
age_seconds=12.4,
ttl_remaining_seconds=107.6,
ptr_ttl_remaining_seconds=4321.0,
)
tracker = ReachabilityTracker(
mdns_cache_reader={"kitchen": info}.get,
)

snap = _snapshot(tracker)
assert snap["mdns_last_seen_seconds_ago"] == 12.4
assert snap["mdns_ttl_remaining_seconds"] == 107.6
# The PTR's own TTL — the drawer's "offline in N" countdown —
# is carried separately from the freshest-record union TTL.
assert snap["mdns_ptr_ttl_remaining_seconds"] == 4321.0


def test_snapshot_mdns_null_when_cache_reader_returns_none() -> None:
Expand All @@ -86,6 +94,7 @@ def test_snapshot_mdns_null_when_cache_reader_returns_none() -> None:
snap = _snapshot(tracker)
assert snap["mdns_last_seen_seconds_ago"] is None
assert snap["mdns_ttl_remaining_seconds"] is None
assert snap["mdns_ptr_ttl_remaining_seconds"] is None
assert snap["mdns_txt_records"] is None


Expand Down
6 changes: 6 additions & 0 deletions tests/test_state_monitor_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,10 @@ def test_get_mdns_cache_info_picks_latest_across_record_types() -> None:
assert info is not None
# PTR (5s ago) is fresher than A (110s ago) → PTR wins.
assert info.age_seconds == pytest.approx(5.0, abs=0.5)
# PTR TTL is carried separately as the offline-countdown
# horizon: 4500s TTL aged 5s → ~4495s remaining, distinct
# from the freshest-record union TTL above.
assert info.ptr_ttl_remaining_seconds == pytest.approx(4495.0, abs=1.0)
finally:
zc.close()

Expand Down Expand Up @@ -1057,6 +1061,8 @@ def test_get_mdns_cache_info_picks_latest_record() -> None:
# and the one inside ``get_mdns_cache_info``.
assert info.age_seconds == pytest.approx(5.0, abs=0.5)
assert info.ttl_remaining_seconds == pytest.approx(115.0, abs=0.5)
# No PTR cached (lookup stubbed to None) → no offline-countdown horizon.
assert info.ptr_ttl_remaining_seconds is None


async def test_refresh_mdns_no_zeroconf_is_a_noop() -> None:
Expand Down
Loading