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_CAP,
|
||||||
ADB_RECONNECT_BACKOFF_START,
|
ADB_RECONNECT_BACKOFF_START,
|
||||||
ADB_RECONNECT_JITTER,
|
ADB_RECONNECT_JITTER,
|
||||||
|
APP_LABEL_TTL,
|
||||||
STATE_STORE_TTL,
|
STATE_STORE_TTL,
|
||||||
SYSTEM_MEDIA_PACKAGES,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -195,6 +200,11 @@ class ShieldConnectionManager:
|
|||||||
# publishes no MediaSession (TiviMate, R2). TTL-capped: 'playing'
|
# publishes no MediaSession (TiviMate, R2). TTL-capped: 'playing'
|
||||||
# is NEVER resurrected past STATE_STORE_TTL.
|
# is NEVER resurrected past STATE_STORE_TTL.
|
||||||
self._last_known: dict[str, tuple[MediaSessionInfo, float]] = {}
|
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.
|
# Injectable clock keeps the TTL expiry tests deterministic.
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
self._tasks: list[asyncio.Task] = []
|
self._tasks: list[asyncio.Task] = []
|
||||||
@@ -470,6 +480,51 @@ class ShieldConnectionManager:
|
|||||||
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||||
return snapshot
|
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]:
|
def last_known_map(self) -> dict[str, MediaSessionInfo]:
|
||||||
"""TTL-filtered snapshot of the per-package last-known cache."""
|
"""TTL-filtered snapshot of the per-package last-known cache."""
|
||||||
result: dict[str, MediaSessionInfo] = {}
|
result: dict[str, MediaSessionInfo] = {}
|
||||||
@@ -593,6 +648,8 @@ class ShieldConnectionManager:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
self._listeners.clear()
|
self._listeners.clear()
|
||||||
self._last_known.clear()
|
self._last_known.clear()
|
||||||
|
self._app_labels.clear()
|
||||||
|
self._app_label_requests.clear()
|
||||||
if self._poller is not None:
|
if self._poller is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self._poller.disconnect()
|
await self._poller.disconnect()
|
||||||
@@ -719,6 +776,8 @@ class ShieldConnectionManager:
|
|||||||
foreground = await poller.poll_foreground()
|
foreground = await poller.poll_foreground()
|
||||||
if foreground:
|
if foreground:
|
||||||
self.store.set(FIELD_FOREGROUND, foreground)
|
self.store.set(FIELD_FOREGROUND, foreground)
|
||||||
|
# P5-1: manifest labels resolve on demand, one package per cycle
|
||||||
|
self._drain_app_label_requests()
|
||||||
else:
|
else:
|
||||||
# Standby: playback data is not current — expire it.
|
# Standby: playback data is not current — expire it.
|
||||||
self.store.set(FIELD_SESSIONS, [])
|
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.
|
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
||||||
CONF_APP_NAME_MAP = "app_name_map"
|
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
|
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||||
# (same security posture as the core androidtv integration).
|
# (same security posture as the core androidtv integration).
|
||||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||||
|
|||||||
@@ -544,21 +544,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def app_name(self):
|
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
|
1. CONF_APP_NAME_MAP (the user's explicit override)
|
||||||
push tiers; unmapped packages stay raw (icons follow HA's media
|
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
||||||
image lookup by name).
|
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
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
||||||
if atv is not None:
|
if atv is not None:
|
||||||
app_name = atv.attributes.get("app_name")
|
app_name = atv.attributes.get("app_name")
|
||||||
if app_name:
|
if app_name:
|
||||||
return app_name
|
return app_name
|
||||||
if self._manager is not None:
|
if self._manager is not None and package:
|
||||||
package = self._manager.current_app
|
return package
|
||||||
if package:
|
|
||||||
return self._app_name_map.get(package, package)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
Reference in new issue
Block a user