fix(connection): ADB outage clears only ADB-derived store fields — protocol push truth survives by TTL (F2)

This commit is contained in:
James Mackay committed 2026-10-08 10:03:27 +01:00
1 parent b451638c65
commit 8462ad494c
3 files changed
+108 -8

No files matched your search

+18 -6
View File
@@ -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.
"""
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
@@ -7,10 +7,11 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
"iot_class": "local_polling",
"iot_class": "local_push",
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
"requirements": [
"adb-shell>=0.4.0"
"adb-shell>=0.4.0",
"androidtvremote2>=0.1.0"
],
"version": "2026.10.08"
}
+87
View File
@@ -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)