Reported from a live install: with ADB enabled but unreachable and a paired, healthy protocol transport, the media player went unavailable even though the protocol still served power/app/volume. The old rule was ``manager.connected or manager.awake is not None or not manager.available`` — i.e. "a configured ADB manager that is down ⇒ the whole entity is down" (t5-F2), which ignored transport B entirely. The ladder is now: * protocol transport wired and not reported unreachable — usable; * or ADB connected — usable; * or fresh wake data (the poller has spoken) — usable; * or a passive manager (no ADB configured at all) — usable, because nothing to transport is not a broken transport. Unavailable only when a CONFIGURED transport is actively down and no usable data exists. One implementation, ``entity_available`` (media_player.py), is used by the media player, the remote (whose ladder previously duplicated a weaker version of this rule) and the diagnostics payload. Diagnostics grew ``transport.entity_available`` plus ``adb.configured`` so a support dump answers "why is my entity unavailable?" — the ladder inputs and its verdict are now in the same place. Pinned by tests: the exact user case (ADB configured+down, protocol up ⇒ available), all-down (⇒ unavailable), wake-data rescue, connected ADB without a protocol, the passive manager, a configured-but-down transport with no data, the remote following the same ladder, and the diagnostics verdict. Verified: 310 passed / 2 skipped, 311 passed / 1 skipped (androidtvremote2), 16 passed in the HA drift suite, ruff clean.
109 lines
4.0 KiB
Python
109 lines
4.0 KiB
Python
"""Entry diagnostics for Nvidia Shield (P4-2b, t21 F1 platform-module move).
|
|
|
|
HA discovers ``async_get_config_entry_diagnostics`` through the integration's
|
|
``diagnostics`` platform module — a placement inside media_player.py never
|
|
serves in a real install (t20 finding F1).
|
|
|
|
The payload: redacted entry config (data + options), the manager's StateStore
|
|
snapshot via the public ``store_snapshot`` accessor, and both transports'
|
|
health. Cert paths are kept readable; key material / pairing secrets never
|
|
exist in stored state, and any key carrying a secret-shaped name is redacted
|
|
defensively.
|
|
"""
|
|
|
|
import time
|
|
from dataclasses import asdict, is_dataclass
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.redact import async_redact_data
|
|
|
|
from .connection import ShieldConnectionManager
|
|
from .media_player import entity_available
|
|
|
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
|
_REDACTED = "**REDACTED**"
|
|
|
|
# G14: the documented explicit redaction field list — the audit calls out
|
|
# config fields like the hosts; pairing/key-shaped fields are masked by
|
|
# async_redact_data and the marker scan adds a second layer.
|
|
_TO_REDACT = {
|
|
"pairing_code",
|
|
"adb_pairing_code",
|
|
"protocol_pairing_code",
|
|
"serial_number",
|
|
"uuid",
|
|
"adb_host",
|
|
"protocol_host",
|
|
"host",
|
|
}
|
|
|
|
|
|
def _redact(obj):
|
|
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
|
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
|
if isinstance(obj, dict):
|
|
return {
|
|
key: (
|
|
_REDACTED
|
|
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
|
|
else _redact(value)
|
|
)
|
|
for key, value in obj.items()
|
|
}
|
|
if isinstance(obj, list):
|
|
return [_redact(item) for item in obj]
|
|
return obj
|
|
|
|
|
|
def _store_snapshot(manager) -> dict[str, dict]:
|
|
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
|
|
snapshot = {}
|
|
for name, (stamp, value) in manager.store.values_stamped().items():
|
|
if is_dataclass(value) and not isinstance(value, type):
|
|
value = asdict(value)
|
|
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
|
return snapshot
|
|
|
|
|
|
async def async_get_config_entry_diagnostics(
|
|
hass: HomeAssistant, entry: ConfigEntry
|
|
) -> dict:
|
|
"""Entry diagnostics: redacted config + store snapshot + transport health."""
|
|
manager = getattr(entry, "runtime_data", None)
|
|
payload: dict = {
|
|
"entry": {
|
|
"data": dict(getattr(entry, "data", {}) or {}),
|
|
"options": dict(getattr(entry, "options", {}) or {}),
|
|
},
|
|
"store": {},
|
|
"transport": {},
|
|
}
|
|
if isinstance(manager, ShieldConnectionManager):
|
|
payload["store"] = manager.store_snapshot()
|
|
payload["transport"] = {
|
|
# t40: the ladder's verdict, from the same helper the entities use,
|
|
# so a support dump answers "why is my entity unavailable?".
|
|
"entity_available": entity_available(manager),
|
|
"adb": {
|
|
"configured": manager.available,
|
|
"connected": manager.connected,
|
|
"awake": manager.awake,
|
|
},
|
|
"protocol": {
|
|
"active": manager.protocol_active,
|
|
"available": manager.is_available,
|
|
"is_on": manager.is_on,
|
|
},
|
|
}
|
|
# G14 (t30) + t37 D2: the documented helper is the primary redaction path
|
|
# with an explicit field list (the hosts included), and the marker scan
|
|
# above runs defensively after it. It is imported at MODULE scope from
|
|
# homeassistant.helpers.redact — the real module; the old
|
|
# `homeassistant.util.diagnostic` never existed, and the swallowed
|
|
# ImportError turned the whole pass into a silent no-op.
|
|
return _redact(async_redact_data(payload, _TO_REDACT))
|
|
|
|
|
|
__all__ = ["async_get_config_entry_diagnostics"]
|