diff --git a/custom_components/nvidia_shield/adb_session.py b/custom_components/nvidia_shield/adb_session.py index 23760ef..807cce9 100644 --- a/custom_components/nvidia_shield/adb_session.py +++ b/custom_components/nvidia_shield/adb_session.py @@ -8,8 +8,11 @@ Re-exports MediaSessionInfo and ensure_adb_key for existing importers. import logging -from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key -from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export) +from .ha_adb_common import ( + AdbClientBase, + MediaSessionInfo, # noqa: F401 (re-export) +) +from .ha_adb_common import ensure_adb_key as _ensure_adb_key _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/nvidia_shield/button.py b/custom_components/nvidia_shield/button.py index ab6c3b2..ed9c9d4 100644 --- a/custom_components/nvidia_shield/button.py +++ b/custom_components/nvidia_shield/button.py @@ -85,7 +85,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity): ensure_adb_key, self.hass.config.config_dir ) - _LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port) + _LOGGER.info( + "Attempting ADB pairing with %s:%d — approve the prompt on the Shield", + host, + port, + ) poller = AdbMediaSessionPoller(host, port, key_path) if await poller.connect(): diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index b1bff1f..73ec0c1 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -3,10 +3,10 @@ import logging import voluptuous as vol - from homeassistant import config_entries from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er, selector +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import selector from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .const import ( diff --git a/custom_components/nvidia_shield/ha_adb_common/adb_client.py b/custom_components/nvidia_shield/ha_adb_common/adb_client.py index fee5024..1232e91 100644 --- a/custom_components/nvidia_shield/ha_adb_common/adb_client.py +++ b/custom_components/nvidia_shield/ha_adb_common/adb_client.py @@ -3,6 +3,7 @@ Subclasses add device-specific polling (media sessions, wake state, etc.). """ +import contextlib import logging from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session @@ -90,18 +91,15 @@ class AdbClientBase: async def disconnect(self) -> None: """Disconnect from the device.""" if self._device: - try: + with contextlib.suppress(Exception): await self._device.close() - except Exception: - pass self._device = None self._connected = False async def shell(self, cmd: str) -> str | None: """Run a shell command, reconnecting if needed.""" - if not self._connected or not self._device: - if not await self.connect(): - return None + if (not self._connected or not self._device) and not await self.connect(): + return None try: return await self._device.shell(cmd) except Exception as err: @@ -187,7 +185,7 @@ class AdbClientBase: seen = set() # System packages to exclude - _EXCLUDED = { + excluded = { "com.android.systemui", "com.android.tv.settings", "com.nvidia.diagtools", @@ -206,7 +204,7 @@ class AdbClientBase: match = re.search(r"I=(\S+)/", stripped) if match: pkg = match.group(1) - if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."): + if pkg not in seen and pkg not in excluded and not pkg.startswith("."): seen.add(pkg) apps.append({"package": pkg}) diff --git a/custom_components/nvidia_shield/ha_adb_common/intent.py b/custom_components/nvidia_shield/ha_adb_common/intent.py index 92d3836..59d7633 100644 --- a/custom_components/nvidia_shield/ha_adb_common/intent.py +++ b/custom_components/nvidia_shield/ha_adb_common/intent.py @@ -33,7 +33,9 @@ class PlaybackIntent: adb_command: str | None = None @classmethod - def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent": + def create( + cls, target_state: str, command: str, prior_state: str | None = None + ) -> "PlaybackIntent": return cls( target_state=target_state, command=command, diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index dd68dac..daadf45 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -16,8 +16,6 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli import logging import time -from .ha_adb_common import PlaybackIntent, running_apps_from_tasks - from homeassistant.components.media_player import ( MediaPlayerDeviceClass, MediaPlayerEntity, @@ -25,12 +23,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event +from homeassistant.const import STATE_PAUSED, STATE_PLAYING +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later, async_track_state_change_event +from .adb_session import AdbMediaSessionPoller from .const import ( ADB_POLL_INTERVAL, ADB_RETRY_INTERVAL, @@ -52,7 +51,7 @@ from .const import ( DEFAULT_ADB_PORT, DOMAIN, ) -from .adb_session import AdbMediaSessionPoller, ensure_adb_key +from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .jellyfin import fetch_session_media_info from .power import discover_power_siblings @@ -121,6 +120,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): self._adb_poll_unsub: CALLBACK_TYPE | None = None self._adb_awake: bool | None = None self._running_apps: list[dict] = [] + # Tracked poll tasks (cancelled on unload) and failure bookkeeping + self._adb_poll_tasks: set = set() + self._adb_failures = 0 + self._removed = False @property def _jellyfin_entity(self) -> str | None: @@ -176,6 +179,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): ) async def async_will_remove_from_hass(self) -> None: + self._removed = True for unsub in self._unsub_listeners: unsub() self._unsub_listeners.clear() @@ -183,6 +187,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): if self._adb_poll_unsub: self._adb_poll_unsub() self._adb_poll_unsub = None + # Cancel in-flight poll tasks so they cannot outlive the entity and + # trigger a surprise reconnect (B2). + for task in self._adb_poll_tasks: + task.cancel() + self._adb_poll_tasks.clear() if self._adb_poller: await self._adb_poller.disconnect() @@ -279,11 +288,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): new_state_obj = event.data.get("new_state") # Power off clears any intent — device is off, intent is moot - if entity_id in (self._androidtv_entity, self._power_entity) and new_state_obj: - if not self._is_power_on(): - if self._intent: - _LOGGER.debug("Intent cleared: device powered off") - self._clear_intent() + if ( + entity_id in (self._androidtv_entity, self._power_entity) + and new_state_obj + and not self._is_power_on() + and self._intent + ): + _LOGGER.debug("Intent cleared: device powered off") + self._clear_intent() # Jellyfin state change — check for intent confirmation and fetch media info if entity_id == self._jellyfin_entity and new_state_obj: @@ -387,22 +399,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): @callback def _adb_poll_callback(self, _now) -> None: - """Schedule ADB poll as a task.""" - self.hass.async_create_task(self._adb_poll()) + """Schedule the ADB poll as a tracked task (cancelled on unload).""" + task = self.hass.async_create_task(self._adb_poll()) + self._adb_poll_tasks.add(task) + task.add_done_callback(self._adb_poll_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: + if not self._adb_poller or self._removed: return # Always check wake state - prev_awake = self._adb_awake self._adb_awake = await self._adb_poller.is_awake() - # Only poll media session when awake and Jellyfin is NOT active - jf = self._read(self._jellyfin_entity) - jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) + 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 @@ -414,19 +433,34 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): self._adb_poller.clear_info() self._running_apps = [] - # Always update state (wake state may have changed) - if self._adb_awake != prev_awake or self._adb_poller.last_info: - self.async_write_ha_state() + # Always update state — any ADB-derived value may have changed + self.async_write_ha_state() except Exception: - _LOGGER.debug("ADB poll error", exc_info=True) + self._on_adb_poll_failure() finally: # Use slow retry if ADB is disconnected, normal interval if connected - if self._adb_enabled: - interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL + 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: """Read stream info from the HA Jellyfin integration's coordinator.""" info = await fetch_session_media_info( @@ -442,11 +476,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): if jf and jf.state in (STATE_PLAYING, STATE_PAUSED): return True # If we have an active intent for play/pause, media is conceptually active - if self._intent and self._intent.target_state in ( - STATE_PLAYING, STATE_PAUSED - ): - return True - return False + return bool( + self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED) + ) # ── State ── @@ -705,7 +737,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): async def async_mute_volume(self, mute): await self._cmd("volume_mute", is_volume_muted=mute) - async def _media_cmd(self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None) -> None: + async def _media_cmd( + self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None + ) -> None: """Send a media command. Uses ADB dispatch for non-Jellyfin apps when available.""" if intent_state: self._set_intent(ha_service, intent_state) diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index 37d1f88..095aa11 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -4,8 +4,8 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_PLAYING, STATE_PAUSED -from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.const import STATE_PAUSED, STATE_PLAYING +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event