refactor(media_player): entity becomes a thin view over the ShieldConnectionManager store; poll loop and socket removed from the entity

This commit is contained in:
James Mackay committed 2026-10-08 09:03:18 +01:00
1 parent 0a7dd887d7
commit 93b766ae97
1 file changed
+60 -161
+60 -161
View File
@@ -6,6 +6,9 @@ State architecture:
- Metadata: Jellyfin attributes - Metadata: Jellyfin attributes
- ALL commands: androidtv_remote (Jellyfin is read-only) - ALL commands: androidtv_remote (Jellyfin is read-only)
ADB connection/polling lives in the entry's ShieldConnectionManager
(connection.py); this entity is a thin callback-view over its StateStore.
Intent tracking bridges the gap between instant commands and slow Jellyfin polling: Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately 1. User presses pause → intent recorded → UI shows paused immediately
2. Command sent to androidtv_remote → Shield pauses on screen 2. Command sent to androidtv_remote → Shield pauses on screen
@@ -14,7 +17,6 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli
""" """
import logging import logging
import os
import time import time
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
@@ -30,17 +32,14 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from .adb_session import AdbMediaSessionPoller from .connection import ShieldConnectionManager
from .const import ( from .const import (
ADB_POLL_INTERVAL,
ADB_RETRY_INTERVAL,
ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING, ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY, ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT, ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS, ATTR_MEDIA_DAG_RUNNING_APPS,
CONF_ADB_ENABLED,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
@@ -50,7 +49,6 @@ from .const import (
DOMAIN, DOMAIN,
) )
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .helpers import adb_key_path, resolve_adb_config
from .jellyfin import fetch_session_media_info from .jellyfin import fetch_session_media_info
from .power import discover_power_siblings from .power import discover_power_siblings
@@ -114,15 +112,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# Jellyfin media stream info (resolution, codec, etc.) # Jellyfin media stream info (resolution, codec, etc.)
self._media_info: dict | None = None self._media_info: dict | None = None
# ADB media session, wake state, and running apps # ADB-derived state comes from the entry's ShieldConnectionManager
self._adb_poller: AdbMediaSessionPoller | None = None # (connection.py) — this entity is a thin callback-view over its store.
self._adb_poll_unsub: CALLBACK_TYPE | None = None self._manager: ShieldConnectionManager | None = None
self._adb_awake: bool | None = None self._manager_unsub: CALLBACK_TYPE | None = None
self._running_apps: list[dict] = [] # Entity-owned background tasks (intent retries, media-info fetch);
# Tracked poll tasks (cancelled on unload) and failure bookkeeping # cancelled on unload. The manager owns the socket — never touch it
self._adb_poll_tasks: set = set() # here directly.
self._adb_failures = 0 self._entity_tasks: set = set()
self._removed = False
@property @property
def _jellyfin_entity(self) -> str | None: def _jellyfin_entity(self) -> str | None:
@@ -149,10 +146,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _power_entity(self) -> str | None: def _power_entity(self) -> str | None:
return self._entry.options.get(CONF_POWER_ENTITY) or None return self._entry.options.get(CONF_POWER_ENTITY) or None
@property
def _adb_enabled(self) -> bool:
return self._entry.options.get(CONF_ADB_ENABLED, False)
@property @property
def _adb_store_info(self) -> bool: def _adb_store_info(self) -> bool:
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True) return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
@@ -162,9 +155,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._power_entity: if self._power_entity:
self._power_sensors = discover_power_siblings(self.hass, self._power_entity) self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
# Set up ADB media session poller # Consume the entry's ShieldConnectionManager (thin-view pattern)
if self._adb_enabled: manager = getattr(self._entry, "runtime_data", None)
await self._setup_adb() if manager is not None:
self._manager = manager
self._manager_unsub = manager.register_listener(
self._on_manager_update
)
entities = [self._androidtv_entity] entities = [self._androidtv_entity]
if self._jellyfin_entity: if self._jellyfin_entity:
@@ -178,21 +175,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
) )
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
self._removed = True
for unsub in self._unsub_listeners: for unsub in self._unsub_listeners:
unsub() unsub()
self._unsub_listeners.clear() self._unsub_listeners.clear()
self._clear_intent() self._clear_intent()
if self._adb_poll_unsub: if self._manager_unsub:
self._adb_poll_unsub() self._manager_unsub()
self._adb_poll_unsub = None self._manager_unsub = None
# Cancel in-flight poll tasks so they cannot outlive the entity and # Cancel in-flight entity tasks so they cannot outlive the entity.
# trigger a surprise reconnect (B2). for task in self._entity_tasks:
for task in self._adb_poll_tasks:
task.cancel() task.cancel()
self._adb_poll_tasks.clear() self._entity_tasks.clear()
if self._adb_poller:
await self._adb_poller.disconnect()
# ── Intent management ── # ── Intent management ──
@@ -254,12 +248,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if ( if (
intent.via_adb intent.via_adb
and intent.adb_command and intent.adb_command
and self._adb_poller is not None and self._manager is not None
and self._adb_poller.connected and self._manager.connected
): ):
# The command was originally served by ADB — retry over ADB. # The command was originally served by ADB — retry over ADB.
self._track_task( self._track_task(
self._adb_poller.send_media_command(intent.adb_command) self._manager.send_media_command(intent.adb_command)
) )
else: else:
self._track_task( self._track_task(
@@ -335,119 +329,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None return None
return self.hass.states.get(entity_id) return self.hass.states.get(entity_id)
async def _setup_adb(self) -> None:
"""Initialize ADB connection and start polling."""
try:
# Only auto-connect if ADB was previously paired (key exists)
key_path = adb_key_path(self.hass)
if not await self.hass.async_add_executor_job(os.path.exists, key_path):
_LOGGER.info(
"ADB key not found — skipping auto-connect. "
"Use the Pair ADB button or reconfigure the integration."
)
return
adb = resolve_adb_config(self.hass, self._entry)
if adb is None:
_LOGGER.warning(
"Could not determine ADB host. Set it in integration options."
)
return
self._adb_poller = AdbMediaSessionPoller(adb.host, adb.port, adb.key_path)
if await self._adb_poller.connect():
# Start periodic polling
self._adb_poll_unsub = async_call_later(
self.hass, ADB_POLL_INTERVAL, self._adb_poll_callback
)
_LOGGER.info("ADB media session polling started for %s", adb.host)
else:
_LOGGER.warning(
"ADB connection to %s failed. If this is the first connection, "
"approve the ADB debugging prompt on the device. "
"Retrying in %d seconds.",
adb.host, ADB_RETRY_INTERVAL,
)
self._adb_poll_unsub = async_call_later(
self.hass, ADB_RETRY_INTERVAL, self._adb_poll_callback
)
except Exception:
_LOGGER.exception("Failed to set up ADB")
@callback @callback
def _adb_poll_callback(self, _now) -> None: def _on_manager_update(self) -> None:
"""Schedule the ADB poll as a tracked task (cancelled on unload).""" """Connection manager's store changed — re-evaluate our state."""
self._track_task(self._adb_poll()) self.async_write_ha_state()
def _track_task(self, coro) -> None: def _track_task(self, coro) -> None:
"""Dispatch a background task and register it for unload cancellation. """Dispatch an entity background task; cancelled on unload.
Any fire-and-forget task that can touch the ADB poller must go through Entity-owned tasks (intent retries, media-info fetch) must never go
here: a bare hass.async_create_task survives entity removal and, with through a bare hass.async_create_task: they would survive removal
shell()'s auto-reconnect, can re-handshake the socket for a removed (reviewer finding F2). The connection manager owns its own tasks and
entity (reviewer finding F2). Cancelation in async_will_remove_from_hass the socket — nothing here touches it directly.
covers poll and intent-retry tasks alike.
""" """
task = self.hass.async_create_task(coro) task = self.hass.async_create_task(coro)
self._adb_poll_tasks.add(task) self._entity_tasks.add(task)
task.add_done_callback(self._adb_poll_tasks.discard) task.add_done_callback(self._entity_tasks.discard)
async def _adb_poll(self) -> None:
"""Poll ADB for wake state and media session, then schedule next poll."""
try:
if not self._adb_poller or self._removed:
return
# Always check wake state
self._adb_awake = await self._adb_poller.is_awake()
if self._adb_awake is None:
# Poll failure (connection dropped, device unreachable) — make
# it visible and clear derived state so nothing stale lingers.
self._on_adb_poll_failure()
return
if self._adb_failures:
self._adb_failures = 0
_LOGGER.info("ADB poll recovered")
if self._adb_awake:
# Always poll ADB when awake — pass the foreground app
# so the parser prefers its session over stale ones
await self._adb_poller.poll(foreground_package=self.app_id)
self._running_apps = await self._adb_poller.poll_running_apps()
elif self._adb_poller.last_info:
# Clear media info when asleep
self._adb_poller.clear_info()
self._running_apps = []
# Always update state — any ADB-derived value may have changed
self.async_write_ha_state()
except Exception:
self._on_adb_poll_failure()
finally:
# Use slow retry if ADB is disconnected, normal interval if connected
if self._adb_enabled and not self._removed:
connected = self._adb_poller is not None and self._adb_poller.connected
interval = ADB_POLL_INTERVAL if connected else ADB_RETRY_INTERVAL
self._adb_poll_unsub = async_call_later(
self.hass, interval, self._adb_poll_callback
)
def _on_adb_poll_failure(self) -> None:
"""Handle a failed poll: log (first failure + periodic repeats) and
clear derived state instead of clinging to the last known values."""
self._adb_failures += 1
if self._adb_failures == 1 or self._adb_failures % 6 == 0:
_LOGGER.warning(
"ADB poll failed (%d consecutive attempts — continuing in the background)",
self._adb_failures,
)
self._adb_awake = None
if self._adb_poller:
self._adb_poller.clear_info()
self._running_apps = []
self.async_write_ha_state()
async def _fetch_media_info(self, entity_picture: str) -> None: async def _fetch_media_info(self, entity_picture: str) -> None:
"""Read stream info from the HA Jellyfin integration's coordinator.""" """Read stream info from the HA Jellyfin integration's coordinator."""
@@ -477,9 +374,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
ADB is the authority — it knows if the device is awake or asleep. ADB is the authority — it knows if the device is awake or asleep.
The plug being "on" just means the outlet has power (standby). The plug being "on" just means the outlet has power (standby).
""" """
# 1. ADB wake state — most accurate # 1. ADB wake state — most accurate (exact mWakefulness parse)
if self._adb_awake is not None: adb_awake = self._manager.awake if self._manager else None
return self._adb_awake if adb_awake is not None:
return adb_awake
# 2. Power entity — only trust "off" (plug pulled = definitely off) # 2. Power entity — only trust "off" (plug pulled = definitely off)
if self._power_entity: if self._power_entity:
@@ -518,8 +416,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
# Tier 3: ADB media session (non-Jellyfin apps) # Tier 3: ADB media session (non-Jellyfin apps)
if self._adb_poller and self._adb_poller.last_info: adb_info = self._manager.best_session(self.app_id) if self._manager else None
adb_info = self._adb_poller.last_info if adb_info:
if adb_info.playing: if adb_info.playing:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
if adb_info.paused: if adb_info.paused:
@@ -533,10 +431,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._jf_active(): if self._jf_active():
return _BASE_FEATURES | _PLAYBACK_FEATURES return _BASE_FEATURES | _PLAYBACK_FEATURES
# ADB media session also supports playback controls # ADB media session also supports playback controls
if self._adb_poller and self._adb_poller.last_info: adb_info = self._manager.best_session(self.app_id) if self._manager else None
adb_info = self._adb_poller.last_info if adb_info and (adb_info.playing or adb_info.paused):
if adb_info.playing or adb_info.paused: return _BASE_FEATURES | _PLAYBACK_FEATURES
return _BASE_FEATURES | _PLAYBACK_FEATURES
return _BASE_FEATURES return _BASE_FEATURES
# ── Metadata from Jellyfin ── # ── Metadata from Jellyfin ──
@@ -615,11 +512,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _get_adb_now_playing(self) -> dict | None: def _get_adb_now_playing(self) -> dict | None:
"""Build now-playing from ADB media session (non-Jellyfin apps).""" """Build now-playing from ADB media session (non-Jellyfin apps)."""
if not self._adb_poller or not self._adb_poller.last_info: info = self._manager.best_session(self.app_id) if self._manager else None
if not info:
return None return None
if not self._adb_store_info: if not self._adb_store_info:
# Privacy mode: show that something is playing but not what # Privacy mode: show that something is playing but not what
info = self._adb_poller.last_info
if info.playing or info.paused: if info.playing or info.paused:
return { return {
"state": "playing" if info.playing else "paused", "state": "playing" if info.playing else "paused",
@@ -628,7 +525,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
} }
return None return None
info = self._adb_poller.last_info
if not info.title: if not info.title:
return None return None
np = { np = {
@@ -660,13 +556,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _get_running_apps(self) -> list[dict] | None: def _get_running_apps(self) -> list[dict] | None:
"""Build a list of running apps from ADB recent tasks + media sessions.""" """Build a list of running apps from ADB recent tasks + media sessions."""
if not self._adb_poller or not self._running_apps: if not self._manager:
return None
apps = self._manager.running_apps
if not apps:
return None return None
if not self._adb_store_info: if not self._adb_store_info:
return None return None
return running_apps_from_tasks( return running_apps_from_tasks(
self._running_apps, apps,
self._adb_poller.all_sessions, self._manager.active_sessions,
foreground_package=self.app_id, foreground_package=self.app_id,
) )
@@ -737,8 +636,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
jf = self._read(self._jellyfin_entity) jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
if self._adb_poller and self._adb_poller.connected and not jf_active: if self._manager and self._manager.connected and not jf_active:
success = await self._adb_poller.send_media_command(adb_command) success = await self._manager.send_media_command(adb_command)
if success: if success:
# The ADB path served this command — the self-heal retry must # The ADB path served this command — the self-heal retry must
# also go over ADB, not back to the remote. # also go over ADB, not back to the remote.
@@ -767,7 +666,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
async def async_bring_to_foreground(self, package: str) -> bool: async def async_bring_to_foreground(self, package: str) -> bool:
"""Bring a specific app to the foreground via ADB.""" """Bring a specific app to the foreground via ADB."""
if not self._adb_poller: if self._manager is None or not self._manager.connected:
_LOGGER.warning("ADB not available for bring_to_foreground") _LOGGER.warning("ADB not available for bring_to_foreground")
return False return False
return await self._adb_poller.bring_to_foreground(package) return await self._manager.bring_to_foreground(package)