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:
1 parent
80e9a691bc
commit
cd0e634a76
3 files changed
+80
-9
No files matched your search
@@ -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, [])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user