From cd0e634a76c34dde3369f40f09ba8cc556898d1b Mon Sep 17 00:00:00 2001 From: James Mackay Date: Fri, 9 Oct 2026 05:48:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(connection,media=5Fplayer):=20on-demand=20?= =?UTF-8?q?manifest-label=20cache=20(=E2=89=A524h=20TTL,=20one=20pm=20dump?= =?UTF-8?q?=20per=20watchdog=20cycle=20via=20the=20task=20pool)=20+=20app?= =?UTF-8?q?=5Fname=20order=20map=20>=20label=20>=20entity=20>=20raw=20pack?= =?UTF-8?q?age=20(P5-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/nvidia_shield/connection.py | 61 ++++++++++++++++++- custom_components/nvidia_shield/const.py | 4 ++ .../nvidia_shield/media_player.py | 24 +++++--- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index eaff17f..0a6ee70 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -36,10 +36,15 @@ from .const import ( ADB_RECONNECT_BACKOFF_CAP, ADB_RECONNECT_BACKOFF_START, ADB_RECONNECT_JITTER, + APP_LABEL_TTL, STATE_STORE_TTL, SYSTEM_MEDIA_PACKAGES, ) -from .ha_adb_common import MediaSessionInfo, parse_audio_playback +from .ha_adb_common import ( + MediaSessionInfo, + parse_application_label, + parse_audio_playback, +) _LOGGER = logging.getLogger(__name__) @@ -195,6 +200,11 @@ class ShieldConnectionManager: # publishes no MediaSession (TiviMate, R2). TTL-capped: 'playing' # is NEVER resurrected past STATE_STORE_TTL. self._last_known: dict[str, tuple[MediaSessionInfo, float]] = {} + # P5-1: device manifest labels — package → (label, stamp). Long-TTL + # (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one + # package per watchdog cycle so a burst never blocks the watchdog. + self._app_labels: dict[str, tuple[str, float]] = {} + self._app_label_requests: set[str] = set() # Injectable clock keeps the TTL expiry tests deterministic. self._clock = clock self._tasks: list[asyncio.Task] = [] @@ -470,6 +480,51 @@ class ShieldConnectionManager: snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value} return snapshot + def app_label(self, package: str | None) -> str | None: + """Cached manifest label for a package (P5-1), or None. + + A cache miss enqueues an on-demand `pm dump` fetch — drained one + package per watchdog cycle via the manager task pool, so entity + refreshes and the watchdog are never blocked by a burst. A failed + resolution simply falls through to the next name tier. + """ + if not package: + return None + cached = self._app_labels.get(package) + if cached is not None: + label, stamp = cached + if self._clock() - stamp <= APP_LABEL_TTL: + return label + self._app_labels.pop(package, None) # stale — refetch on demand + self._app_label_requests.add(package) + return None + + async def _fetch_app_label(self, package: str) -> None: + """One `pm dump` label fetch; failure falls through silently.""" + poller = self._poller + if poller is None or not self.connected: + return + try: + dump = await poller.shell( + f"pm dump {package} 2>/dev/null | head -300" + ) + label = parse_application_label(dump or "") + except Exception as err: + _LOGGER.debug("app label fetch failed for %s: %s", package, err) + return + if label: + self._app_labels[package] = (label, self._clock()) + + def _drain_app_label_requests(self) -> None: + """Start at most one label fetch per watchdog cycle.""" + if not self._app_label_requests or self._poller is None: + return + package = min(self._app_label_requests) + self._app_label_requests.discard(package) + self._tasks.append( + self._create_task(self._fetch_app_label(package), f"app_label_{package}") + ) + def last_known_map(self) -> dict[str, MediaSessionInfo]: """TTL-filtered snapshot of the per-package last-known cache.""" result: dict[str, MediaSessionInfo] = {} @@ -593,6 +648,8 @@ class ShieldConnectionManager: task.cancel() self._listeners.clear() self._last_known.clear() + self._app_labels.clear() + self._app_label_requests.clear() if self._poller is not None: with contextlib.suppress(Exception): await self._poller.disconnect() @@ -719,6 +776,8 @@ class ShieldConnectionManager: foreground = await poller.poll_foreground() if foreground: self.store.set(FIELD_FOREGROUND, foreground) + # P5-1: manifest labels resolve on demand, one package per cycle + self._drain_app_label_requests() else: # Standby: playback data is not current — expire it. self.store.set(FIELD_SESSIONS, []) diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index d6f52b4..0062e43 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-1: device manifest label cache TTL (long — labels change with app +# updates, not per poll). +APP_LABEL_TTL = 24 * 3600 + # P4-2a adb_command service: rawADB shell on this entry — OFF by default # (same security posture as the core androidtv integration). CONF_ADB_COMMAND_ENABLED = "adb_command_enabled" diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 9a8ee94..9375e40 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -544,21 +544,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): @property def app_name(self): - """Entity tier → protocol-push tier → the P4-1 apps map. + """P5-1 resolution order — explicit override first: - The map (package → friendly name) resolves after the entity and - push tiers; unmapped packages stay raw (icons follow HA's media - image lookup by name). + 1. CONF_APP_NAME_MAP (the user's explicit override) + 2. device manifest label (`pm dump`, cached ≥24 h, resolved on + demand — a miss enqueues the fetch and falls through) + 3. androidtv entity tier (when configured) + 4. raw package (protocol push / ADB foreground parse) """ + package = self._manager.foreground_app if self._manager else None + if package: + mapped = self._app_name_map.get(package) + if mapped: + return mapped + label = self._manager.app_label(package) + if label: + return label atv = self._read(self._androidtv_entity) if self._androidtv_entity else None if atv is not None: app_name = atv.attributes.get("app_name") if app_name: return app_name - if self._manager is not None: - package = self._manager.current_app - if package: - return self._app_name_map.get(package, package) + if self._manager is not None and package: + return package return None @property