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:
1 parent
9461d69469
commit
ee3f2845af
4 files changed
+407
-57
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user