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

+94
View File
@@ -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