diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index 478c315..f5eba7d 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -36,8 +36,13 @@ from .const import ( ADB_RECONNECT_BACKOFF_CAP, ADB_RECONNECT_BACKOFF_START, ADB_RECONNECT_JITTER, + APP_ICON_FALLBACKS, + APP_ICON_GENERIC, APP_LABEL_EXIT_MARKER, APP_LABEL_TTL, + APP_LIST_TTL, + APP_SWEEP_CONCURRENCY, + APP_SWEEP_SPACING, CONF_LOGCAT_EVENTS, LOGCAT_SUPPRESS_WINDOW, STATE_STORE_TTL, @@ -46,6 +51,9 @@ from .const import ( from .ha_adb_common import ( MediaSessionInfo, foreground_from_logcat, + icon_for_package, + installed_packages, + is_launchable, parse_application_label, parse_audio_playback, ) @@ -216,6 +224,13 @@ class ShieldConnectionManager: # t55: packages whose label fetch is queued or in flight — a render # during the fetch must not enqueue the same package twice. self._app_label_inflight: set[str] = set() + # t57: the discovered installed-apps table — (rows, stamp), or an empty + # row list with a stamp when ADB cannot answer. APP_LIST_TTL, refreshed + # on demand; never rebuilt on a state poll. + self._installed_apps: tuple[list[dict], float] | None = None + #: t57: the in-flight sweep, so concurrent callers share one sweep + #: instead of stacking a second burst on the same connection. + self._apps_sweep: asyncio.Task | None = None # P5-3 (C-STREAM): the dedicated logcat line-stream consumer. # NEVER shares the poller's ADB socket — its own device object and # connection; CONF_LOGCAT_EVENTS gates it (default off) and the @@ -636,70 +651,71 @@ class ShieldConnectionManager: except Exception as err: _LOGGER.debug("protocol device info unavailable: %s", err) + async def _dump_label(self, package: str) -> str | None: + """One `pm dump` label read: the label, or None for a FAILED fetch. + + The ONE place the dump is issued, shared by the on-demand path + (:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0 + distinction — "the command ran and found no label" versus "the + transport delivered nothing" — cannot drift between them. A successful + fetch is also cached here, so both paths benefit from the 24 h cache and + neither double-stamps. + + Returns the label (possibly "") when the dump RAN, and None when it did + not run or delivered no output at all. + """ + poller = self._poller + if poller is None or not self.connected: + _LOGGER.debug( + "app label for %s not fetched (ADB not connected) — will " + "retry on the next render", + package, + ) + return None + try: + import shlex + + dump = await poller.shell( + f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; " + f"echo {APP_LABEL_EXIT_MARKER}$?" + ) + except Exception as err: + _LOGGER.debug( + "app label fetch failed for %s (%s) — will retry on the " + "next render", + package, + err, + ) + return None + if dump is None or not dump.strip(): + # the transport never delivered output: a FAILED fetch, not an + # answer. Caching here is the t55 defect this repairs. + _LOGGER.debug( + "app label fetch for %s returned no output — will retry on " + "the next render", + package, + ) + return None + label = parse_application_label(dump) + # F1: label-less pm dumps negatively cache too (empty label) so a + # later cycle performs zero additional pm dumps for the package. + self.cache_app_label(package, label) + return label + async def _fetch_app_label(self, package: str) -> None: - """One `pm dump` label fetch; stamps ONLY a fetch that really RAN (t57). + """One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN. - The distinction this method exists for (t59 F1): a FAILED fetch and a - genuinely label-less package are not the same thing, and conflating - them is what kept ``app_name`` on the raw package id forever. - - * the dump ran and carries an ``application-label`` → cache the label; - * the dump ran and carries none → cache the empty string as a - definitive negative (t24 F1: no per-heartbeat round-trip tax for a - label-less package); - * the fetch did NOT run → cache NOTHING, and the next render - re-enqueues. ``ShieldAdbPoller.shell`` answers ``None`` for both - shapes of "did not run" — no connected device, and a command that - raised — so ``None``/blank output is treated as a failure rather - than being read as "the dump ran and found no label". Treating it - as the latter stamped a 24 h definitive negative after ONE transient - failure, and a later good dump could never recover the label. - - The dump also carries an exit marker (``; echo __NSH_EXIT:$?``), which - is what keeps the third branch from swallowing a REAL empty answer: if - the command ran and produced nothing, the marker is still there, so - the negative cache happens (zero further round trips). If the output - is blank the command never delivered anything, and we retry. + The on-demand half of the label path (one package per watchdog cycle, + driven by an entity render). The dump itself — including the t57 UNIT 0 + distinction between a FAILED fetch and a genuinely label-less package — + lives in :meth:`_dump_label`, so the sweep and this path cannot drift. """ self._app_label_inflight.add(package) try: - poller = self._poller - if poller is None or not self.connected: - _LOGGER.debug( - "app label for %s not fetched (ADB not connected) — will " - "retry on the next render", - package, - ) - return - try: - import shlex - - dump = await poller.shell( - f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; " - f"echo {APP_LABEL_EXIT_MARKER}$?" - ) - except Exception as err: - _LOGGER.debug( - "app label fetch failed for %s (%s) — will retry on the " - "next render", - package, - err, - ) - return - if dump is None or not dump.strip(): - # the transport never delivered output: a FAILED fetch, not an - # answer. Caching here is the t55 defect this repairs. - _LOGGER.debug( - "app label fetch for %s returned no output — will retry on " - "the next render", - package, - ) - return - # F1: label-less pm dumps negatively cache too (empty label) so a - # later cycle performs zero additional pm dumps for the package. - self.cache_app_label(package, parse_application_label(dump)) + await self._dump_label(package) finally: self._app_label_inflight.discard(package) + def _on_logcat_app_start(self, package: str) -> None: """Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3). @@ -851,6 +867,164 @@ class ShieldConnectionManager: self._logcat_task.cancel() self._logcat_task = None + # ── t57: installed-app discovery ─────────────────────────────────────── + + def installed_apps(self) -> list[dict]: + """The discovered apps, ``[{package, name, icon}]``, cached (t57). + + Returns the cached table whenever it is fresh, and an EMPTY list when + there is nothing usable yet (ADB disabled, not connected, or the first + sweep still running). A caller that wants the table filled calls + :meth:`async_refresh_installed_apps`; this accessor never blocks and + never issues a shell itself, so a state poll stays free of discovery + work. + """ + if self._installed_apps is None: + return [] + rows, stamp = self._installed_apps + if self._clock() - stamp > APP_LIST_TTL: + return [] # stale — a refresh repopulates it, the poll does not + return [dict(row) for row in rows] + + @property + def installed_apps_stale(self) -> bool: + """True when no fresh table is available (including "never swept").""" + if self._installed_apps is None: + return True + _rows, stamp = self._installed_apps + return self._clock() - stamp > APP_LIST_TTL + + async def async_refresh_installed_apps(self, force: bool = False) -> list[dict]: + """(Re)build the installed-app table over ADB, bounded and shared (t57). + + One sweep at a time: concurrent callers await the same task rather than + starting a second burst, and a fresh table is returned as-is unless + ``force``. The sweep is deliberately NOT part of the watchdog: it is a + handful of shells on demand, not a per-cycle cost. + + Failure is normal and handled without noise — no poller, a disconnected + transport, or a shell that answers nothing stores an EMPTY table with a + stamp, so the caller degrades to "no discovered apps" and nothing + retries in a loop. The next explicit refresh (or the TTL) tries again. + """ + if not force and not self.installed_apps_stale: + return self.installed_apps() + if self._apps_sweep is not None and not self._apps_sweep.done(): + await asyncio.shield(self._apps_sweep) + return self.installed_apps() + task = self._create_task(self._sweep_installed_apps(), "apps_sweep") + self._apps_sweep = task + try: + rows = await task + finally: + if self._apps_sweep is task: + self._apps_sweep = None + return [dict(row) for row in rows] + + async def _sweep_installed_apps(self) -> list[dict]: + """The sweep itself: list, label, icon — then stamp the cache.""" + poller = self._poller + if poller is None or not self.connected: + _LOGGER.debug( + "installed-app discovery skipped (ADB unavailable) — the " + "options page and the app list stay on what is already known" + ) + self._installed_apps = ([], self._clock()) + return [] + try: + user_output = await poller.shell("pm list packages -3") + launcher_output = await poller.shell( + "cmd package resolve-activity --brief -c " + "android.intent.category.HOME" + ) + except Exception as err: # pragma: no cover - shell swallows its own + _LOGGER.debug("installed-app discovery failed: %s", err) + self._installed_apps = ([], self._clock()) + return [] + if user_output is None and launcher_output is None: + # the transport delivered nothing at all: no list is an ANSWER + _LOGGER.debug( + "installed-app discovery got no output — will try again on the " + "next refresh" + ) + self._installed_apps = ([], self._clock()) + return [] + + packages = [p for p in installed_packages(user_output, launcher_output) + if is_launchable(p)] + labels = await self._sweep_labels(packages) + rows = [ + { + "package": package, + "name": labels.get(package) or self.app_label(package) or package, + "icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC), + } + for package in packages + ] + self._installed_apps = (rows, self._clock()) + _LOGGER.debug("installed-app discovery found %d apps", len(rows)) + return rows + + async def _sweep_labels(self, packages: list[str]) -> dict[str, str]: + """Labels for a whole sweep, in a bounded burst (t57). + + Deliberately NOT through ``_app_label_requests``: that queue is drained + ONE package per watchdog cycle, which is right for the live entity path + (a render must never fan out into shells) but would take a couple of + minutes for a Shield with ~40 apps. Here the fetches run + ``APP_SWEEP_CONCURRENCY`` at a time with a short spacing, so a typical + Shield finishes in seconds while the socket still sees a trickle rather + than a flood. + + Cached labels are reused, so a second sweep costs almost nothing; a + failed fetch is NOT cached (t57 UNIT 0) and the app falls back to its + package id for this table while the next sweep retries it. + """ + cached: dict[str, str] = {} + missing: list[str] = [] + for package in packages: + label = self.app_label(package) + if label: + cached[package] = label + else: + missing.append(package) + if not missing: + return cached + + semaphore = asyncio.Semaphore(APP_SWEEP_CONCURRENCY) + + async def _one(package: str) -> tuple[str, str | None]: + async with semaphore: + # a small spacing keeps the burst polite on a single socket + await asyncio.sleep(APP_SWEEP_SPACING) + return package, await self._dump_label(package) + + results = await asyncio.gather( + *(_one(package) for package in missing), return_exceptions=True + ) + for result in results: + if isinstance(result, BaseException): + continue # one bad package never fails the sweep + package, label = result + if label: + cached[package] = label + else: + # None here means the fetch FAILED (nothing cached); the empty + # string means the device answered with no label. Both leave + # the row on its package id, and both keep the app in the list. + cached[package] = "" + return cached + + + """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 _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: diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 3833564..8e77e2f 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -151,6 +151,45 @@ DEFAULT_APP_DIRECTORY = { "com.google.android.youtube.tv": "YouTube", } +# t57: best-effort icon per discovered app. `pm dump` does NOT expose the +# app's icon resource (an extracted APK's icon is a bitmap only its own code +# can resolve, and `dumpsys package` prints no drawable reference), so a device +# icon is not obtainable over plain shell. These MDI names are therefore a +# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest +# about being a guess. The user's own `app_icon_map` (a picture URL or camera +# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture. +APP_ICON_FALLBACKS = { + "ar.tvplayer.tv": "mdi:television-classic", + "com.github.damontecres.wholphin": "mdi:jellyfish", + "com.netflix.ninja": "mdi:netflix", + "com.google.android.youtube.tv": "mdi:youtube-tv", + "com.google.android.tvlauncher": "mdi:view-dashboard", + "org.xbmc.kodi": "mdi:kodi", + "com.plexapp.android": "mdi:plex", + "com.amazon.amazonvideo.livingroom": "mdi:amazon", + "com.disney.disneyplus": "mdi:movie-open", + "com.spotify.tv.android": "mdi:spotify", + "com.google.android.apps.youtube.music": "mdi:music", + "com.valvesoftware.steamlink": "mdi:gamepad-variant", + "com.spotify.music": "mdi:spotify", +} +#: The fallback for an app this map has never heard of. +APP_ICON_GENERIC = "mdi:television" + +# t57: installed-app discovery. The package list changes only when the user +# installs or uninstalls something, so a long TTL is right; refreshing is +# explicit (the options page and the app-list sensor ask for it) rather than a +# per-poll cost. +APP_LIST_TTL = 6 * 3600 +#: `pm dump` sweeps for labels run in a small burst — enough to finish a +#: typical Shield quickly, few enough that the label fetches never queue +#: behind each other on one socket or stall the watchdog. +APP_SWEEP_CONCURRENCY = 4 +#: Spacing between the fetches of one sweep, per worker. Keeps the burst a +#: trickle on the one socket the watchdog uses, while ~40 apps still finish in +#: a few seconds instead of a few minutes. +APP_SWEEP_SPACING = 0.05 + CHANNEL_DIGIT_DELAY = 0.1 CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay" CONF_CHANNEL_SEND_ENTER = "channel_send_enter" diff --git a/custom_components/nvidia_shield/ha_adb_common/__init__.py b/custom_components/nvidia_shield/ha_adb_common/__init__.py index 7bb54d3..409f371 100644 --- a/custom_components/nvidia_shield/ha_adb_common/__init__.py +++ b/custom_components/nvidia_shield/ha_adb_common/__init__.py @@ -11,6 +11,13 @@ from .const import ( ATTR_MEDIA_DAG_RUNNING_APPS, ) from .foreground import parse_foreground +from .installed_apps import ( + icon_for_package, + installed_packages, + is_launchable, + parse_active_launcher, + parse_package_list, +) from .intent import PlaybackIntent from .keys import ensure_adb_key from .logcat_events import foreground_from_logcat @@ -38,6 +45,11 @@ __all__ = [ "parse_audio_playback", "parse_foreground", "foreground_from_logcat", + "icon_for_package", + "installed_packages", + "is_launchable", + "parse_active_launcher", + "parse_package_list", "parse_wakefulness", "parse_media_session", "running_apps_from_sessions", diff --git a/custom_components/nvidia_shield/ha_adb_common/installed_apps.py b/custom_components/nvidia_shield/ha_adb_common/installed_apps.py new file mode 100644 index 0000000..44ce0f5 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/installed_apps.py @@ -0,0 +1,125 @@ +"""Discover the apps actually installed on the Shield (t57). + +The live complaint ("it doesn't find all the apps") came from the curated +fallback list: only four packages were ever offered, so a user with Wholphin, +SmartTube, Kodi, Plex or anything else saw nothing. The device knows its own +apps, so this module asks it. + +The commands (both plain `pm`, available on Shield Experience 9 / Android 11 +with no extra permission beyond the ADB pairing this integration already has): + +* ``pm list packages -3`` — third-party (user-installed) packages, one + ``package:`` line each. This is what the live complaint is about: an app + the user installed is a ``-3`` package. +* ``cmd package resolve-activity --brief -c android.intent.category.HOME`` + — the ACTIVE launcher (``com.google.android.tvlauncher/....MainActivity``). + A launcher is a system app, so it is not in ``-3``, yet it is exactly the app + a user sees and may want to name or launch — so it is added explicitly. + +System apps are otherwise NOT enumerated: the Shield ships a few hundred of +them and they are not launchable apps a media-player source list wants. The +one system app that matters (the launcher) is resolved, not filtered. + +Labels come from the existing ``pm dump`` + ``parse_application_label`` path and +its 24 h cache (``ShieldConnectionManager.app_label``), so discovery adds no +second naming mechanism. + +Icons are best effort and explicitly heuristic — see ``icon_for_package``. +""" + +from __future__ import annotations + +import re + +#: One `pm list packages` line: ``package:com.example.app``. +_PACKAGE_LINE_RE = re.compile(r"^package:(?P[\w.]+)\s*$", re.MULTILINE) + +#: `resolve-activity --brief` prints ``package/activity`` on the last line, +#: possibly preceded by the (verbose) intent header. +_COMPONENT_RE = re.compile(r"^(?P[\w.]+)/[\w.$]+$", re.MULTILINE) + +#: Activity components that belong to a launcher but are not what the user +#: opens: settings/hidden entry points a HOME resolution can land on. +_LAUNCHER_NOISE = ("com.android.tv.settings",) + + +def parse_package_list(output: str | None) -> list[str]: + """The package ids in a ``pm list packages`` dump, in device order. + + Tolerant on purpose: the real output has one ``package:`` per line, and + a shell that prepends noise (adb banners, warnings) must not lose the list. + Duplicates are collapsed, order preserved. + """ + if not output: + return [] + seen: set[str] = set() + packages: list[str] = [] + for match in _PACKAGE_LINE_RE.finditer(output): + package = match.group("package") + if package not in seen: + seen.add(package) + packages.append(package) + return packages + + +def parse_active_launcher(output: str | None) -> str | None: + """The active HOME package from ``cmd package resolve-activity --brief``. + + Android prints a verbose intent header and then a single + ``package/activity`` line; only the last component-looking line is the + answer. Returns None when nothing resolved (a device with no launcher, or a + command that produced no output) — the caller treats that as "no launcher + to add" rather than as a failure. + """ + if not output: + return None + packages = _COMPONENT_RE.findall(output) + for package in reversed(packages): + if package not in _LAUNCHER_NOISE: + return package + return None + + +def installed_packages(user_output: str | None, launcher_output: str | None) -> list[str]: + """The discovered package ids: user apps first, then the active launcher. + + The launcher goes last so a source list built from this keeps the user's own + apps at the top, and it is de-duplicated because a launcher CAN be a + third-party package (a replacement TV launcher installed from the store). + """ + packages = parse_package_list(user_output) + launcher = parse_active_launcher(launcher_output) + if launcher and launcher not in packages: + packages.append(launcher) + return packages + + +def icon_for_package(package: str, curated: dict[str, str] | None = None, + generic: str = "mdi:television") -> str: + """A best-effort MDI icon for a package, never a non-string (t57). + + HEURISTIC, and labelled as such: the icon is looked up in a curated + package -> MDI map, and anything unknown gets the generic fallback. There is + no device query behind this because there cannot be one — ``pm dump`` does + not print the app's icon resource, and resolving an extracted APK's bitmap + would need code the app itself runs. The user's own icon column (a picture + URL or a ``camera.*`` entity) is what ``entity_picture`` consumes, and it + outranks this. + + Returns a string in every case: the UI is never handed None or a dict. + """ + if not package: + return generic + table = curated if curated is not None else {} + return table.get(package) or generic + + +def is_launchable(package: str) -> bool: + """Cheap sanity filter for a package id. + + Not a real Android query: a package id is dotted and non-empty. The filter + exists so a malformed line cannot become a table row or a source entry. + """ + return bool(package) and "." in package and all( + part.isalnum() or part in ("_", "-") for part in package.split(".") + )