diff --git a/custom_components/nvidia_shield/diagnostics.py b/custom_components/nvidia_shield/diagnostics.py index dad6078..d4f550a 100644 --- a/custom_components/nvidia_shield/diagnostics.py +++ b/custom_components/nvidia_shield/diagnostics.py @@ -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, diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 1ad49fe..3ca8e7c 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -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: diff --git a/custom_components/nvidia_shield/remote.py b/custom_components/nvidia_shield/remote.py index b85d592..503bec3 100644 --- a/custom_components/nvidia_shield/remote.py +++ b/custom_components/nvidia_shield/remote.py @@ -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: diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 3aaec52..03f9b16 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -1174,6 +1174,19 @@ class TestBrowseMediaDirectory: assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH +class DownAdbPoller(FakeAdbPoller): + """A CONFIGURED ADB poller that cannot reach the device (t40 repro).""" + + connected = False + + +def make_manager_with(*, poller=None, protocol=None): + """A manager with exactly these transports (None = not configured).""" + return ShieldConnectionManager( + hass=None, entry=None, poller=poller, protocol=protocol, store=None + ) + + def make_app_sensor(modules, manager, entry, *, atv_state=None): """A Current App sensor over the given manager/entry (t40).""" from types import SimpleNamespace @@ -1264,3 +1277,84 @@ class TestCurrentAppSensorFriendlyName: player._manager._on_current_app("ar.tvplayer.tv") assert sensor.native_value == player.app_name == "TiviMate" + + +class TestAvailabilityLadder: + """t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE. + + The t5-F2 rule took the whole entity down whenever a configured ADB manager + was disconnected, even though the paired protocol transport was still + serving power/app/volume. + """ + + def _player(self, modules, *, poller=None, protocol=None): + player, _served, _intents = make_player( + modules, manager=make_manager_with(poller=poller, protocol=protocol) + ) + player._entry.runtime_data = player._manager + return player + + def test_the_users_case_keeps_the_entity_available(self, modules): + player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient()) + player._manager._on_is_available(True) + + assert player._manager.available is True # ADB is configured ... + assert player._manager.connected is False # ... but unreachable + assert player.available is True # the protocol carries the entity + + def test_unavailable_when_every_configured_transport_is_down(self, modules): + player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient()) + player._manager._on_is_available(False) + + assert player.available is False + + def test_fresh_wake_data_is_enough(self, modules): + player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient()) + player._manager._on_is_available(False) + player._manager.store.set("awake", False) + + assert player.available is True + + def test_a_connected_adb_carries_a_protocol_less_entry(self, modules): + player = self._player(modules, poller=FakeAdbPoller(), protocol=None) + + assert player._manager.protocol_active is False + assert player.available is True + + def test_a_passive_manager_stays_available(self, modules): + player = self._player(modules, poller=None, protocol=None) + + assert player._manager.available is False # no ADB configured at all + assert player.available is True + + def test_a_configured_but_down_transport_is_unavailable(self, modules): + player = self._player(modules, poller=DownAdbPoller(), protocol=None) + + assert player.available is False + + def test_the_remote_follows_the_same_ladder(self, modules): + from types import SimpleNamespace + + player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient()) + player._manager._on_is_available(True) + remote = modules["remote"].NvidiaShieldRemoteEntity( + SimpleNamespace(), player._entry + ) + + assert remote.available is True + + def test_the_diagnostics_dump_explains_the_ladder(self, modules): + player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient()) + player._manager._on_is_available(True) + + from nsh.diagnostics import async_get_config_entry_diagnostics + + entry = player._entry + entry.data = {} + entry.options = {} + payload = asyncio.run(async_get_config_entry_diagnostics(None, entry)) + + transport = payload["transport"] + assert transport["entity_available"] is True + assert transport["adb"] == {"configured": True, "connected": False, "awake": None} + assert transport["protocol"]["active"] is True