From a3b397920ee727efa61255b5bbe05b1d747a1f2c Mon Sep 17 00:00:00 2001 From: James Mackay Date: Fri, 9 Oct 2026 05:57:34 +0100 Subject: [PATCH] test(config_flow,media_player,remote): digit-delay/send-enter options (store + window/error validation), IME-first text entry with ADB fallback tier, icon column parse/prefill/store (P5-2) --- .../nvidia_shield/config_flow.py | 47 +++++-- custom_components/nvidia_shield/connection.py | 35 ++++++ custom_components/nvidia_shield/const.py | 4 + .../nvidia_shield/media_player.py | 30 ++++- custom_components/nvidia_shield/remote.py | 14 +++ tests/test_command_routing.py | 93 ++++++++++++++ tests/test_standalone_setup.py | 115 ++++++++++++++++-- 7 files changed, 320 insertions(+), 18 deletions(-) diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 8ebc4f4..4cd0096 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -20,6 +20,7 @@ from .const import ( CONF_ADB_PORT, CONF_ADB_STORE_APP_INFO, CONF_ANDROIDTV_ENTITY, + CONF_APP_ICON_MAP, CONF_APP_NAME_MAP, CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_SEND_ENTER, @@ -80,13 +81,27 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None: return None -def _app_map_to_text(app_map: dict) -> str: - """Serialize the apps map to one ``package=name`` per line.""" - return "\n".join(f"{pkg}={name}" for pkg, name in app_map.items()) +def _app_map_to_text(app_map: dict, icon_map: dict | None = None) -> str: + """Serialize the apps map to one ``package=name[|icon]`` per line.""" + icon_map = icon_map or {} + lines = [] + for pkg, name in app_map.items(): + icon = icon_map.get(pkg) + lines.append(f"{pkg}={name}|{icon}" if icon else f"{pkg}={name}") + return "\n".join(lines) -def _app_map_from_text(text: str) -> dict[str, str] | None: - """Parse one ``package=name`` per line; None on malformed input.""" +def _app_map_from_text( + text: str, icons: dict[str, str] | None = None +) -> dict[str, str] | None: + """Parse one ``package=name[|icon]`` per line; None on malformed input. + + The optional second column (a picture URL or entity id) feeds + entity_picture through CONF_APP_ICON_MAP; single-column maps stay + valid. Malformed icon text (spaces) invalidates the row the same way + a malformed name does. + """ + icons = icons if icons is not None else {} app_map: dict[str, str] = {} for line in text.split("\n"): line = line.strip() @@ -94,11 +109,18 @@ def _app_map_from_text(text: str) -> dict[str, str] | None: continue if "=" not in line: return None - package, name = line.split("=", 1) + package, value = line.split("=", 1) + parts = value.strip().split("|", 1) + name = parts[0].strip() + icon = parts[1].strip() if len(parts) > 1 else "" package = package.strip() name = name.strip() if not package or not name or " " in package: return None + if icon: + if " " in icon: + return None + icons[package] = icon app_map[package] = name return app_map @@ -511,12 +533,13 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): if delay is not None and not (0.05 <= delay <= 1.0): errors["base"] = "invalid_digit_delay" app_map_text = user_input.pop(CONF_APP_NAME_MAP, None) + icons: dict[str, str] = {} if app_map_text is None: app_map = self._options.get(CONF_APP_NAME_MAP, {}) elif app_map_text.strip() == "": app_map = {} else: - app_map = _app_map_from_text(app_map_text) + app_map = _app_map_from_text(app_map_text, icons) if app_map is None: errors["base"] = "invalid_app_map" if not errors: @@ -524,16 +547,22 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): self._options[CONF_CHANNEL_DIGIT_DELAY] = delay self._options.update(user_input) self._options[CONF_APP_NAME_MAP] = app_map + # P5-2: the icon column — blank/sparse keeps prior entries? + # No: the editor is the source of truth, so a save without + # icons clears them (single-column stays valid). + self._options[CONF_APP_ICON_MAP] = icons return self.async_create_entry(title="", data=self._options) sel = _build_selectors() schema_dict = {} # Apps name/icon map editor (P4-1, core androidtv_remote options - # pattern, simplified): one package=name per line. + # pattern, simplified): one package=name[|icon] per line (P5-2 adds + # the optional icon column). current_map = self._options.get(CONF_APP_NAME_MAP, {}) + current_icons = self._options.get(CONF_APP_ICON_MAP, {}) if current_map: - app_map_default = _app_map_to_text(current_map) + app_map_default = _app_map_to_text(current_map, current_icons) schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str else: schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index b25b749..deebe1a 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -495,6 +495,41 @@ class ShieldConnectionManager: snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value} return snapshot + async def send_text_ime(self, text: str) -> bool: + """Text entry via the protocol client's send_text (P5-2). + + The Shield may swallow the text when its on-screen virtual keyboard + is up (RESEARCH_IDEAS §2 availability gate) — the call reports the + send outcome; callers fall back to the ADB input-text path only + when the protocol transport is down or the send failed. + """ + if self._protocol is None: + return False + try: + self._protocol.send_text(text) + return True + except Exception as err: + _LOGGER.debug("protocol send_text failed: %s", err) + return False + + async def send_adb_input_text(self, text: str) -> bool: + """ADB fallback for text entry: `input text` (P5-2). + + Used only when the protocol send_text path is unavailable/failed. + Shell-quoted (shlex) so single quotes and spaces survive. + """ + import shlex + + poller = self._poller + if poller is None or not self.connected: + return False + try: + result = await poller.shell(f"input text {shlex.quote(text)}") + return result is not None + except Exception as err: + _LOGGER.debug("adb input text failed: %s", err) + return False + def app_label(self, package: str | None) -> str | None: """Cached manifest label for a package (P5-1), or None. diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 0062e43..14ef099 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -96,6 +96,10 @@ ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local." # consumed by app_name after the androidtv-entity and protocol-push tiers. CONF_APP_NAME_MAP = "app_name_map" +# P5-2: optional icon column (package|icon) — package → icon URL, feeding +# entity_picture when no Jellyfin session picture is live. +CONF_APP_ICON_MAP = "app_icon_map" + # P5-1: device manifest label cache TTL (long — labels change with app # updates, not per poll). APP_LABEL_TTL = 24 * 3600 diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index fe9c8eb..37e308e 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -50,6 +50,7 @@ from .const import ( CONF_ADB_COMMAND_ENABLED, CONF_ADB_STORE_APP_INFO, CONF_ANDROIDTV_ENTITY, + CONF_APP_ICON_MAP, CONF_APP_NAME_MAP, CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_SEND_ENTER, @@ -629,7 +630,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): @property def media_image_url(self): - return self._jf_attr("entity_picture") if self._jf_active() else None + """Jellyfin session picture, else the P5-2 apps-map icon override.""" + if self._jf_active(): + jf = self._jf_attr("entity_picture") + if jf: + return jf + package = self._manager.foreground_app if self._manager else None + if package: + icon_map = self._entry.options.get(CONF_APP_ICON_MAP, {}) + return icon_map.get(package) + return None @property def volume_level(self) -> float | None: @@ -949,7 +959,25 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): Channel path replicates HA core androidtv_remote exactly: digits only, configurable inter-digit delay (default 0.1 s), no ENTER — and the padded prior in-flight channel sequence is cancelled first. + TEXT entry (P5-2): play_media("text", "...") — protocol IME send_text + first; the ADB `input text` fallback tier runs only when the + protocol send is unavailable or failed (the Shield may swallow IME + text while its on-screen keyboard is up, RESEARCH_IDEAS §2). """ + if media_type == "text": + if not media_id: + _LOGGER.warning("play_media(text): empty text ignored") + return + if self._manager is not None and await self._manager.send_text_ime(media_id): + return + if self._manager is not None and await self._manager.send_adb_input_text(media_id): + return + _LOGGER.warning( + "play_media(text): no transport available (protocol down, ADB" + " unavailable) — text not sent" + ) + return + if media_type == "channel": digits = [c for c in media_id if c.isdigit()] if not digits: diff --git a/custom_components/nvidia_shield/remote.py b/custom_components/nvidia_shield/remote.py index 6f7d8b3..b85d592 100644 --- a/custom_components/nvidia_shield/remote.py +++ b/custom_components/nvidia_shield/remote.py @@ -174,3 +174,17 @@ class NvidiaShieldRemoteEntity(RemoteEntity): await manager.send_protocol_key(key_name, direction) if delay_secs > 0: await asyncio.sleep(delay_secs) + + async def async_send_text(self, text: str) -> bool: + """Text entry (P5-2) via the protocol send_text (remote surface). + + Falls back to ADB `input text` only when the protocol transport is + down or the send failed (the Shield may swallow IME text while its + on-screen keyboard is up, RESEARCH_IDEAS §2). + """ + manager = self._manager_of() + if manager is None or not text: + return False + if await manager.send_text_ime(text): + return True + return await manager.send_adb_input_text(text) diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index fd64808..b1b3b33 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -724,6 +724,99 @@ class TestAdbCommandService: assert not player.hass.services.has_service("nvidia_shield", "adb_command") +class TestTextEntry: + """P5-2: text entry — protocol IME first, ADB input-text fallback.""" + + def _player_with_texts(self, modules): + player, _s, _i = make_player(modules) + protocol = player._manager._protocol + protocol.text_calls = [] + protocol.send_text = lambda t: protocol.text_calls.append(t) or True + shell_texts = [] + + async def _shell(cmd): + shell_texts.append(cmd) + return "ok" + + player._manager._poller.shell = _shell + return player, protocol, shell_texts + + async def test_protocol_ime_sends_first(self, modules): + player, protocol, shell_texts = self._player_with_texts(modules) + + await player.async_play_media("text", "hello world") + + assert protocol.text_calls == ["hello world"] + assert shell_texts == [] # ADB fallback not used + + async def test_adb_fallback_when_the_protocol_send_fails(self, modules): + player, protocol, shell_texts = self._player_with_texts(modules) + + def _fail(text): + protocol.text_calls.append(text) + raise RuntimeError("ime closed") + + protocol.send_text = _fail + await player.async_play_media("text", "hello 'world'") + + import shlex + + sample = "hello " + chr(39) + "world" + chr(39) + assert shell_texts == [f"input text {shlex.quote(sample)}"] + + async def test_empty_text_is_ignored(self, modules): + player, protocol, shell_texts = self._player_with_texts(modules) + + await player.async_play_media("text", "") + + assert protocol.text_calls == [] and shell_texts == [] + player._manager._protocol.send_text = lambda t: True + await player.async_play_media("text", "") + assert protocol.text_calls == [] + + +class TestIconOverride: + """P5-2: optional icon column → CONF_APP_ICON_MAP → entity_picture.""" + + def _player_with_icon(self, modules, icon_map): + player, _s, _i = make_player(modules) + player._entry.options["app_icon_map"] = icon_map + return player + + async def test_icon_serves_when_no_jellyfin_picture(self, modules): + import asyncio + + player = self._player_with_icon( + modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"} + ) + manager = player._manager + manager._on_current_app("ar.tvplayer.tv") + await asyncio.sleep(0.05) + + assert player.media_image_url == "http://icons.local/tivi.png" + + async def test_no_icon_for_an_unmatched_package(self, modules): + import asyncio + + player = self._player_with_icon( + modules, {"com.netflix.ninja": "http://icons.local/netflix.png"} + ) + player._manager._on_current_app("ar.tvplayer.tv") + await asyncio.sleep(0.05) + + assert player.media_image_url is None + + async def test_jellyfin_picture_wins_over_the_icon_map(self, modules): + + player = self._player_with_icon( + modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"} + ) + player._jf_active = lambda: True + player._jf_attr = lambda attr: "http://jf.local/pic.jpg" + + assert player.media_image_url == "http://jf.local/pic.jpg" + + class TestServiceRegistryKey: """F4: the participant key is computed once — add/remove always match.""" diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 9633051..f84fbaa 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -374,17 +374,116 @@ def _cmd_player(modules): return player, None, None +class TestAppIconColumn: + """P5-2: the editor's optional icon column (package=name|icon).""" + + async def test_two_column_line_stores_the_icon(self, tmp_path): + module = _form_module() + entry = SimpleNamespace(options={}, title="Shield") + flow = module.NvidiaShieldOptionsFlow(entry) + flow.hass = FakeHass(str(tmp_path)) + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"}) + + assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} + assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"} + + async def test_single_column_map_stays_valid(self, tmp_path): + module = _form_module() + entry = SimpleNamespace(options={}, title="Shield") + flow = module.NvidiaShieldOptionsFlow(entry) + flow.hass = FakeHass(str(tmp_path)) + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"}) + + assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"} + assert flow._options["app_icon_map"] == {} # blank-safe (no icons) + + async def test_prefill_round_trips_the_icon_column(self): + module = _form_module() + + # the serializer writes the second column through on prefill + text = module._app_map_to_text( + {"ar.tvplayer.tv": "TiviMate"}, + {"ar.tvplayer.tv": "http://i.local/t.png"}, + ) + assert text == "ar.tvplayer.tv=TiviMate|http://i.local/t.png" + + # and the parser round-trips it back + icons: dict[str, str] = {} + app_map = module._app_map_from_text(text, icons) + assert app_map == {"ar.tvplayer.tv": "TiviMate"} + assert icons == {"ar.tvplayer.tv": "http://i.local/t.png"} + + async def test_malformed_icon_invalidates_the_row(self, tmp_path): + module = _form_module() + entry = SimpleNamespace(options={}, title="Shield") + flow = module.NvidiaShieldOptionsFlow(entry) + flow.hass = FakeHass(str(tmp_path)) + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"}) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_app_map"} + + +class TestChannelEntryOptions: + """P5-2: digit-delay + optional send-enter on the options page.""" + + def _options_flow(self, tmp_path, options): + from types import SimpleNamespace as DefaultEntry + + module = _form_module() + entry = DefaultEntry(options=dict(options) if options else {}) + flow = module.NvidiaShieldOptionsFlow(entry) + hass = FakeHass(str(tmp_path)) + flow.hass = hass + flow._options = dict(options) if options else {} + return module, flow + + async def test_digit_delay_and_send_enter_store(self, tmp_path): + module, flow = self._options_flow(tmp_path, options={}) + + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True}) + + assert flow._options["channel_digit_delay"] == 0.3 + assert flow._options["channel_send_enter"] is True + + async def test_digit_delay_out_of_window_rejects(self, tmp_path): + module, flow = self._options_flow(tmp_path, options={}) + + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + result = await flow.async_step_init({"channel_digit_delay": "2.0"}) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_digit_delay"} + + async def test_digit_delay_not_a_number_rejects(self, tmp_path): + module, flow = self._options_flow(tmp_path, options={}) + + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} + + result = await flow.async_step_init({"channel_digit_delay": "slow"}) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_digit_delay"} + + class TestLegacyUniqueIdMigration: """P5-1: one-time legacy androidtv-entity-id unique_id migration.""" - def _entry_module(self): - import entity_stublib - - entity_stublib.install() - if "nsh.media_player" not in sys.modules: - entity_stublib.load_entity_modules() - return sys.modules["nsh.media_player"] - async def test_migrates_to_entry_id_without_protocol(self, tmp_path): import entity_stublib