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