fix(connection): ADB outage clears only ADB-derived store fields — protocol push truth survives by TTL (F2)
This commit is contained in:
1 parent
b451638c65
commit
8462ad494c
3 files changed
+121
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user