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:
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 homeassistant.helpers.redact import async_redact_data
|
||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
|
from .media_player import entity_available
|
||||||
|
|
||||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
_REDACTED = "**REDACTED**"
|
_REDACTED = "**REDACTED**"
|
||||||
@@ -81,7 +82,14 @@ async def async_get_config_entry_diagnostics(
|
|||||||
if isinstance(manager, ShieldConnectionManager):
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
payload["store"] = manager.store_snapshot()
|
payload["store"] = manager.store_snapshot()
|
||||||
payload["transport"] = {
|
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": {
|
"protocol": {
|
||||||
"active": manager.protocol_active,
|
"active": manager.protocol_active,
|
||||||
"available": manager.is_available,
|
"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)
|
_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(
|
def resolve_app_name(
|
||||||
package: str | None,
|
package: str | None,
|
||||||
app_name_map,
|
app_name_map,
|
||||||
@@ -225,15 +254,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
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
|
ADB enabled but unreachable no longer takes the entity down: a paired,
|
||||||
unreliable until the watchdog reconnects (t5-F2).
|
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)
|
return entity_available(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
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _jellyfin_entity(self) -> str | None:
|
def _jellyfin_entity(self) -> str | None:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
||||||
|
from .media_player import entity_available
|
||||||
|
|
||||||
_START_PREFIX = "START_LONG:"
|
_START_PREFIX = "START_LONG:"
|
||||||
_END_PREFIX = "END_LONG:"
|
_END_PREFIX = "END_LONG:"
|
||||||
@@ -93,17 +94,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
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:
|
The same ladder as the media player (``entity_available``): protocol
|
||||||
nothing to transport is not a broken one.
|
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()
|
return entity_available(self._manager_of())
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
|
||||||
return True
|
|
||||||
if not manager.protocol_active:
|
|
||||||
return True
|
|
||||||
return manager.is_available is not False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def activity(self) -> str | None:
|
def activity(self) -> str | None:
|
||||||
|
|||||||
@@ -1174,6 +1174,19 @@ class TestBrowseMediaDirectory:
|
|||||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
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):
|
def make_app_sensor(modules, manager, entry, *, atv_state=None):
|
||||||
"""A Current App sensor over the given manager/entry (t40)."""
|
"""A Current App sensor over the given manager/entry (t40)."""
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -1264,3 +1277,84 @@ class TestCurrentAppSensorFriendlyName:
|
|||||||
player._manager._on_current_app("ar.tvplayer.tv")
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
|
||||||
assert sensor.native_value == player.app_name == "TiviMate"
|
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
|
||||||
Reference in new issue
Block a user