feat(discovery): enumerate the apps installed on the Shield (t57)

The live complaint ("it doesn't find all the apps") came from the curated
list: only four packages were ever offered, so anything the user had
installed elsewhere was invisible. The device knows its own apps.

ha_adb_common/installed_apps.py parses the two queries the sweep makes:

* `pm list packages -3` — every third-party (user-installed) package;
* `cmd package resolve-activity --brief -c android.intent.category.HOME`
  — the ACTIVE launcher, a system app that is nonetheless what the user
  opens, so it is appended rather than filtered away.

System apps are otherwise not enumerated (a Shield ships a few hundred and
they are not launchable apps); the parsers are tolerant of the noise a real
adb shell can prepend and of duplicate lines.

ShieldConnectionManager gains the sweep:
`async_refresh_installed_apps(force=False)` returns a cached
`[{package, name, icon}]` table, and `installed_apps()` is a pure read that
never issues a shell — so a state poll costs nothing.

Naming stays on the one path: the sweep calls `_dump_label`, the same
`pm dump` + parse + 24 h cache the entity path uses, now extracted so the
two cannot drift, including the t57 UNIT 0 rule that a fetch that returned
nothing is NOT cached. Labels are fetched APP_SWEEP_CONCURRENCY (4) at a
time with APP_SWEEP_SPACING (50 ms), so a typical Shield finishes in
seconds rather than the minutes a one-per-watchdog-cycle drain would take
while the socket still sees a trickle.

Failure is a normal outcome: no poller, a disconnected transport or a mute
shell stores an EMPTY table with a stamp and returns [], so nothing retries
in a loop and every surface degrades to "no discovered apps".

Icons: `pm dump` does NOT expose an app's icon resource — an extracted
APK's icon is a bitmap only its own code resolves — so this is honestly a
heuristic: APP_ICON_FALLBACKS (a curated package -> mdi: map) with
APP_ICON_GENERIC (mdi:television) for anything unknown, and always a string.
This commit is contained in:
James Mackay committed 2026-10-12 07:28:50 +01:00
1 parent 9461d69469
commit ee3f2845af
4 files changed
+407 -57

No files matched your search

+231 -57
View File
@@ -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: