fix(availability): F3 — any usable transport keeps the entities up (t40)

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.
This commit is contained in:
James Mackay committed 2026-10-11 06:02:46 +01:00
1 parent dd23d01a88
commit 60d884b76a
4 files changed
+144 -17

No files matched your search

@@ -19,6 +19,7 @@ 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**"
@@ -81,7 +82,14 @@ async def async_get_config_entry_diagnostics(
if isinstance(manager, ShieldConnectionManager):
payload["store"] = manager.store_snapshot()
payload["transport"] = {
"adb": {"connected": manager.connected, "awake": manager.awake},
# 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,
@@ -153,6 +153,35 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
def entity_available(manager: ShieldConnectionManager | None) -> bool:
"""Is ANY usable transport healthy for this entry? (t40 live install)
The single ladder behind ``media_player.available``, ``remote.available``
and the diagnostics payload, so the three cannot disagree:
* the protocol transport is wired and has not reported the device
unreachable — it serves power/app/volume even when ADB is down;
* or ADB is connected (live state data);
* or fresh wake data exists (the poller has spoken at least once);
* or the manager is passive (no ADB configured at all): nothing to
transport is not a broken transport.
Unavailable only when a CONFIGURED transport is actively down and no usable
data exists. The t5-F2 rule ("an ADB manager that is down means unreliable
state") took the whole entity down while a healthy paired protocol transport
was still serving commands — the reported defect.
"""
if not isinstance(manager, ShieldConnectionManager):
return True
if manager.protocol_active and manager.is_available is not False:
return True
if manager.connected:
return True
if manager.awake is not None:
return True
return not manager.available
def resolve_app_name(
package: str | None,
app_name_map,
@@ -225,15 +254,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property
def available(self) -> bool:
"""The bridge entities stay functional without ADB (passive manager).
"""Available while ANY usable transport is healthy (t40 live install).
Unavailable only while an active ADB manager is down — its data is
unreliable until the watchdog reconnects (t5-F2).
ADB enabled but unreachable no longer takes the entity down: a paired,
available protocol transport still serves power/app/volume. The ladder
is shared with the remote and diagnostics (``entity_available``).
"""
manager = getattr(self._entry, "runtime_data", None)
if not isinstance(manager, ShieldConnectionManager):
return True
return manager.connected or manager.awake is not None or not manager.available
return entity_available(getattr(self._entry, "runtime_data", None))
@property
def _jellyfin_entity(self) -> str | None:
+7 -9
View File
@@ -25,6 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
from .media_player import entity_available
_START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:"
@@ -93,17 +94,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
@property
def available(self) -> bool:
"""Unavailable while the transport is active but unpaired/down.
"""Available while ANY usable transport can serve a command (t40).
A passive manager (no protocol transport) keeps the entity available:
nothing to transport is not a broken one.
The same ladder as the media player (``entity_available``): protocol
wired+available, ADB connected, fresh wake data or a passive manager all
count as usable, and a passive manager stays available — nothing to
transport is not a broken one.
"""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return True
if not manager.protocol_active:
return True
return manager.is_available is not False
return entity_available(self._manager_of())
@property
def activity(self) -> str | None: