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)

This commit is contained in:
James Mackay committed 2026-10-09 06:00:17 +01:00
1 parent 3d026ae60e
commit a3b397920e
7 files changed
+320 -18

No files matched your search

+38 -9
View File
@@ -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
@@ -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.
+4
View File
@@ -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
@@ -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:
+14
View File
@@ -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)