diff --git a/docs/API.md b/docs/API.md index 551526546..b9ee64ff1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/esphome_device_builder/controllers/_device_state_monitor/mdns.py b/esphome_device_builder/controllers/_device_state_monitor/mdns.py index 4a2953f67..4ceb5d3da 100644 --- a/esphome_device_builder/controllers/_device_state_monitor/mdns.py +++ b/esphome_device_builder/controllers/_device_state_monitor/mdns.py @@ -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), ) diff --git a/esphome_device_builder/controllers/_reachability_tracker.py b/esphome_device_builder/controllers/_reachability_tracker.py index a1d2cc525..360d07c44 100644 --- a/esphome_device_builder/controllers/_reachability_tracker.py +++ b/esphome_device_builder/controllers/_reachability_tracker.py @@ -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. @@ -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) @@ -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 @@ -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 @@ -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)), diff --git a/esphome_device_builder/controllers/devices/controller.py b/esphome_device_builder/controllers/devices/controller.py index 712fda4a0..0e211f4c8 100644 --- a/esphome_device_builder/controllers/devices/controller.py +++ b/esphome_device_builder/controllers/devices/controller.py @@ -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) diff --git a/esphome_device_builder/models/devices.py b/esphome_device_builder/models/devices.py index a3b323d64..2a0fa993d 100644 --- a/esphome_device_builder/models/devices.py +++ b/esphome_device_builder/models/devices.py @@ -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 diff --git a/tests/controllers/devices/test_subscribe_reachability.py b/tests/controllers/devices/test_subscribe_reachability.py index f43a17cc0..260328be4 100644 --- a/tests/controllers/devices/test_subscribe_reachability.py +++ b/tests/controllers/devices/test_subscribe_reachability.py @@ -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 diff --git a/tests/test_reachability_tracker.py b/tests/test_reachability_tracker.py index 64f8d8630..9e963c8f0 100644 --- a/tests/test_reachability_tracker.py +++ b/tests/test_reachability_tracker.py @@ -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, @@ -69,7 +70,11 @@ 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, ) @@ -77,6 +82,9 @@ def test_snapshot_uses_mdns_cache_reader() -> None: 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: @@ -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 diff --git a/tests/test_state_monitor_reachability.py b/tests/test_state_monitor_reachability.py index 19a3cbfcf..eb33582fd 100644 --- a/tests/test_state_monitor_reachability.py +++ b/tests/test_state_monitor_reachability.py @@ -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() @@ -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: