From 5f8c3290cc2c7bfde26336f251b6c638c684f894 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Thu, 8 Oct 2026 10:12:12 +0100 Subject: [PATCH] fix: flow VERSION=2 for new entries; optional ENTER gains the ADB keyevent-66 fallback; CI installs androidtvremote2 for the real-library tests (F4, F5, F6) --- .gitea/workflows/test.yml | 6 +++-- .../nvidia_shield/config_flow.py | 2 +- custom_components/nvidia_shield/connection.py | 6 +++++ .../nvidia_shield/ha_adb_common/adb_client.py | 8 +++++++ .../nvidia_shield/media_player.py | 12 +++++++--- tests/test_command_routing.py | 22 +++++++++++++++++++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index cd14e91..2a5d14c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -2,7 +2,9 @@ # # The test suite is stdlib-only (parsers, intents, running-apps) and runs the # vendored ha_adb_common helpers without installing Home Assistant — see -# tests/conftest.py. Real firmware captures live in tests/fixtures +# tests/conftest.py. androidtvremote2 is installed so the real-library +# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real +# firmware captures live in tests/fixtures # (how to capture more: docs/RESEARCH_SHIELD.md §8). name: Test @@ -19,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - name: Install test tooling - run: python3 -m pip install --quiet pytest pytest-asyncio ruff + run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2 - name: Lint run: ruff check custom_components tests diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 8b4c87d..0198be8 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -96,7 +96,7 @@ def _build_selectors(): class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Nvidia Shield.""" - VERSION = 1 + VERSION = 2 def __init__(self): self._user_data = {} diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index 20d3755..ac42aaa 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -451,6 +451,12 @@ class ShieldConnectionManager: return False return await self._poller.send_digit_keyevent(digit) + async def send_enter_key(self) -> bool: + """ENTER via the ADB keyevent path (KEYCODE_ENTER = 66, §7 fallback).""" + if not self.available: + return False + return await self._poller.send_enter_keyevent() + # ── Lifecycle ── async def async_start(self) -> None: diff --git a/custom_components/nvidia_shield/ha_adb_common/adb_client.py b/custom_components/nvidia_shield/ha_adb_common/adb_client.py index aafb8b4..12d9547 100644 --- a/custom_components/nvidia_shield/ha_adb_common/adb_client.py +++ b/custom_components/nvidia_shield/ha_adb_common/adb_client.py @@ -174,6 +174,14 @@ class AdbClientBase: _LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit) return result is not None + async def send_enter_keyevent(self) -> bool: + """Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER + fallback for the §7 channel sequence.""" + result = await self.shell("input keyevent 66") + if result is not None: + _LOGGER.debug("ADB ENTER keyevent (66)") + return result is not None + async def bring_to_foreground(self, package: str) -> bool: """Bring an app to the foreground using monkey command.""" result = await self.shell( diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 2758770..066535a 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -834,9 +834,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): media={"media_content_id": digit, "media_content_type": "channel"}, ) await asyncio.sleep(delay) - if send_enter: - _LOGGER.debug("play_media(channel): optional ENTER key sent") - await self._manager.send_protocol_key("KEYCODE_ENTER") + if ( + send_enter + and self._manager is not None + and not await self._manager.send_protocol_key("KEYCODE_ENTER") + ): + # ENTER fallback chain: protocol key → ADB keyevent 66. + # (No legacy-entity tier: the external androidtv_remote has + # no ENTER service to delegate to.) + await self._manager.send_enter_key() except asyncio.CancelledError: raise diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index ba6dd96..0a12c28 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -55,6 +55,7 @@ class FakeAdbPoller: def __init__(self): self.media_calls: list[str] = [] self.digits: list[str] = [] + self.enter_calls: list[bool] = [] self.results: dict[str, bool] = {} self.digit_result = True @@ -70,6 +71,10 @@ class FakeAdbPoller: self.digits.append(digit) return self.digit_result + async def send_enter_keyevent(self) -> bool: + self.enter_calls.append(True) + return True + async def bring_to_foreground(self, package: str) -> bool: return True @@ -214,6 +219,23 @@ class TestChannelDigits: assert player._manager._poller.digits == ["5"] # ADB digit keyevent served assert served == [] + async def test_optional_enter_falls_back_to_adb_keyevent(self, modules): + player, served, _i = make_player(modules) + protocol = player._manager._protocol + poller = player._manager._poller + protocol.fail_keys.add("KEYCODE_ENTER") + protocol.key_calls.clear() + + player._entry.options["channel_send_enter"] = True # §7 optional ENTER on + + await player._channel_entry_sequence(["4", "1"]) # digits fine, ENTER fails + + # digits served by protocol; ENTER retried over ADB keyevent 66 + assert ("KEYCODE_4", "SHORT") in protocol.key_calls + assert poller.digits == [] # digits were fine over the protocol + assert poller.enter_calls == [True] + assert served == [] + async def test_legacy_fallback_when_both_transports_down(self, modules): player, served, _intents = make_player(modules) protocol = player._manager._protocol