diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index f0a5f43..20d3755 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -53,17 +53,20 @@ FIELD_IS_AVAILABLE = "is_available" FIELD_CURRENT_APP = "current_app" FIELD_VOLUME_INFO = "volume_info" -ALL_FIELDS = ( - FIELD_AWAKE, - FIELD_SESSIONS, - FIELD_AUDIO_PLAYBACK, - FIELD_RUNNING_APPS, +# ADB-derived fields (cleared when the ADB connection goes down — they are +# regenerated by the watchdog); protocol pushes (IS_ON/IS_AVAILABLE/ +# CURRENT_APP/VOLUME_INFO) are device-reported truth and expire by TTL only, +# so an ADB outage never clobbers them. +ADB_FIELDS = (FIELD_AWAKE, FIELD_SESSIONS, FIELD_AUDIO_PLAYBACK, FIELD_RUNNING_APPS) +PROTOCOL_FIELDS = ( FIELD_IS_ON, FIELD_IS_AVAILABLE, FIELD_CURRENT_APP, FIELD_VOLUME_INFO, ) +ALL_FIELDS = ADB_FIELDS + PROTOCOL_FIELDS + def volume_tuple(info) -> tuple | None: """Normalize the protocol's volume push to ``(level, max, muted)``. @@ -206,7 +209,14 @@ class ShieldConnectionManager: the constructor; the import stays local so this module remains importable where androidtvremote2 is not installed. """ - import androidtvremote2 + try: + import androidtvremote2 + except ImportError: + _LOGGER.warning( + "androidtvremote2 is not installed — the remote-protocol " + "transport is disabled and the entry degrades to ADB-only." + ) + return None return androidtvremote2.AndroidTVRemote( client_name=client_name, @@ -547,7 +557,9 @@ class ShieldConnectionManager: await self._poller.disconnect() except Exception: _LOGGER.debug("disconnect during reconnect decision failed", exc_info=True) - self.store.clear(*ALL_FIELDS) + # Only ADB-derived fields expire here: protocol pushes are + # device-reported truth and must survive an ADB outage (t11 F2). + self.store.clear(*ADB_FIELDS) self._notify() @staticmethod diff --git a/custom_components/nvidia_shield/manifest.json b/custom_components/nvidia_shield/manifest.json index bbc8fd4..2712306 100644 --- a/custom_components/nvidia_shield/manifest.json +++ b/custom_components/nvidia_shield/manifest.json @@ -1,16 +1,17 @@ { - "domain": "nvidia_shield", - "name": "NVIDIA Shield", - "codeowners": [ - "@notexpectedyet" - ], - "config_flow": true, - "dependencies": [], - "documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield", - "iot_class": "local_polling", - "issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues", - "requirements": [ - "adb-shell>=0.4.0" - ], - "version": "2026.10.08" + "domain": "nvidia_shield", + "name": "NVIDIA Shield", + "codeowners": [ + "@notexpectedyet" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield", + "iot_class": "local_push", + "issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues", + "requirements": [ + "adb-shell>=0.4.0", + "androidtvremote2>=0.1.0" + ], + "version": "2026.10.08" } diff --git a/tests/test_protocol_transport.py b/tests/test_protocol_transport.py index 9420279..5ed32e3 100644 --- a/tests/test_protocol_transport.py +++ b/tests/test_protocol_transport.py @@ -8,12 +8,15 @@ against the real library's VolumeInfo TypedDict when the package is present """ import asyncio +import sys import time import pytest from nsh.connection import ( + FIELD_AWAKE, FIELD_IS_AVAILABLE, FIELD_IS_ON, + FIELD_SESSIONS, FIELD_VOLUME_INFO, ShieldConnectionManager, StateStore, @@ -260,6 +263,90 @@ class TestPushEdgeCases: await manager.async_stop() +class FakeAdbForOutage: + """ADB poller stub for the outage test (wake poll fails).""" + + def __init__(self): + self.connected = True + self.awake = None + self.disconnect_calls = 0 + + async def connect(self): + self.connected = True + return True + + async def disconnect(self): + self.disconnect_calls += 1 + self.connected = False + + async def is_awake(self): + return self.awake if self.connected else None + + +class TestAdbOutageKeepsProtocolTruth: + async def test_note_failure_clears_only_adb_fields(self): + """F2: an ADB outage must not clobber device-reported protocol truth.""" + poller = FakeAdbForOutage() + poller.connected = True + poller.awake = None # wake poll fails → cycle failure path + client = FakeProtocolClient() + manager = ShieldConnectionManager( + hass=None, entry=None, poller=poller, protocol=client + ) + await manager._start_protocol() + + client.push_is_on(True) + client.push_is_available(True) + client.push_current_app("com.netflix.ninja") + client.push_volume_info({"level": 5, "max": 15, "muted": False}) + manager.store.set(FIELD_AWAKE, True) + manager.store.set(FIELD_SESSIONS, ["session-data"]) + + await manager._watchdog_cycle(5.0) # ADB outage → _note_failure + + # ADB-derived fields cleared (regenerated by the watchdog) + assert manager.store.get(FIELD_AWAKE) is None + assert manager.store.get(FIELD_SESSIONS, []) == [] + # protocol push truth survives the ADB outage (TTL expiry rules it) + assert manager.is_on is True + assert manager.is_available is True + assert manager.current_app == "com.netflix.ninja" + assert manager.volume_info == (5.0, 15.0, False) + await manager.async_stop() + + +class TestImportGuard: + def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog): + """F1: a protocol-enabled entry without androidtvremote2 keeps + running ADB-only — the guarded factory logs a warning and builds + nothing instead of raising at setup.""" + import logging + + monkeypatch.setitem(sys.modules, "androidtvremote2", None) + with caplog.at_level(logging.WARNING): + client = ShieldConnectionManager.build_protocol_client( + "shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem" + ) + assert client is None + assert any("degrades to ADB-only" in r.message for r in caplog.records) + + async def test_settings_entry_without_library_stays_passive(self, monkeypatch): + manager = ShieldConnectionManager( + hass=None, + entry=None, + protocol_settings={ + "host": "shield.local", + "client_name": "HA", + "certfile": "/tmp/c.pem", + "keyfile": "/tmp/k.pem", + }, + ) + monkeypatch.setitem(sys.modules, "androidtvremote2", None) + await manager._start_protocol() + assert manager._protocol is None # passive, no crash + await manager.async_stop() + + class TestVolumeTuple: def test_normalizes_typed_dict(self): assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)