feat(connection,media_player): on-demand manifest-label cache (≥24h TTL, one pm dump per watchdog cycle via the task pool) + app_name order map > label > entity > raw package (P5-1)

This commit is contained in:
James Mackay committed 2026-10-09 05:48:39 +01:00
1 parent 80e9a691bc
commit cd0e634a76
3 files changed
+80 -9

No files matched your search

+60 -1
View File
@@ -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, [])