fix: make ADB poll failures visible and stop stale-state cling; cancel tracked poll tasks on unload (B2, B5-prep); lint cleanups across daemon modules

This commit is contained in:
James Mackay committed 2026-10-08 07:45:45 +01:00
1 parent 359b96ef9c
commit d3afda0a25
7 files changed
+86 -45

No files matched your search

@@ -8,8 +8,11 @@ Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
import logging import logging
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key from .ha_adb_common import (
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export) AdbClientBase,
MediaSessionInfo, # noqa: F401 (re-export)
)
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
+5 -1
View File
@@ -85,7 +85,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
ensure_adb_key, self.hass.config.config_dir 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) poller = AdbMediaSessionPoller(host, port, key_path)
if await poller.connect(): if await poller.connect():
@@ -3,10 +3,10 @@
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback 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 .adb_session import AdbMediaSessionPoller, ensure_adb_key
from .const import ( from .const import (
@@ -3,6 +3,7 @@
Subclasses add device-specific polling (media sessions, wake state, etc.). Subclasses add device-specific polling (media sessions, wake state, etc.).
""" """
import contextlib
import logging import logging
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
@@ -90,18 +91,15 @@ class AdbClientBase:
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Disconnect from the device.""" """Disconnect from the device."""
if self._device: if self._device:
try: with contextlib.suppress(Exception):
await self._device.close() await self._device.close()
except Exception:
pass
self._device = None self._device = None
self._connected = False self._connected = False
async def shell(self, cmd: str) -> str | None: async def shell(self, cmd: str) -> str | None:
"""Run a shell command, reconnecting if needed.""" """Run a shell command, reconnecting if needed."""
if not self._connected or not self._device: if (not self._connected or not self._device) and not await self.connect():
if not await self.connect(): return None
return None
try: try:
return await self._device.shell(cmd) return await self._device.shell(cmd)
except Exception as err: except Exception as err:
@@ -187,7 +185,7 @@ class AdbClientBase:
seen = set() seen = set()
# System packages to exclude # System packages to exclude
_EXCLUDED = { excluded = {
"com.android.systemui", "com.android.systemui",
"com.android.tv.settings", "com.android.tv.settings",
"com.nvidia.diagtools", "com.nvidia.diagtools",
@@ -206,7 +204,7 @@ class AdbClientBase:
match = re.search(r"I=(\S+)/", stripped) match = re.search(r"I=(\S+)/", stripped)
if match: if match:
pkg = match.group(1) 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) seen.add(pkg)
apps.append({"package": pkg}) apps.append({"package": pkg})
@@ -33,7 +33,9 @@ class PlaybackIntent:
adb_command: str | None = None adb_command: str | None = None
@classmethod @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( return cls(
target_state=target_state, target_state=target_state,
command=command, command=command,
+63 -29
View File
@@ -16,8 +16,6 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli
import logging import logging
import time import time
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerDeviceClass, MediaPlayerDeviceClass,
MediaPlayerEntity, MediaPlayerEntity,
@@ -25,12 +23,13 @@ from homeassistant.components.media_player import (
MediaPlayerState, MediaPlayerState,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo 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 .const import ( from .const import (
ADB_POLL_INTERVAL, ADB_POLL_INTERVAL,
ADB_RETRY_INTERVAL, ADB_RETRY_INTERVAL,
@@ -52,7 +51,7 @@ from .const import (
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DOMAIN, 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 .jellyfin import fetch_session_media_info
from .power import discover_power_siblings from .power import discover_power_siblings
@@ -121,6 +120,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._adb_poll_unsub: CALLBACK_TYPE | None = None self._adb_poll_unsub: CALLBACK_TYPE | None = None
self._adb_awake: bool | None = None self._adb_awake: bool | None = None
self._running_apps: list[dict] = [] 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 @property
def _jellyfin_entity(self) -> str | None: def _jellyfin_entity(self) -> str | None:
@@ -176,6 +179,7 @@ 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()
@@ -183,6 +187,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._adb_poll_unsub: if self._adb_poll_unsub:
self._adb_poll_unsub() self._adb_poll_unsub()
self._adb_poll_unsub = None 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: if self._adb_poller:
await self._adb_poller.disconnect() await self._adb_poller.disconnect()
@@ -279,11 +288,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
new_state_obj = event.data.get("new_state") new_state_obj = event.data.get("new_state")
# Power off clears any intent — device is off, intent is moot # 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 (
if not self._is_power_on(): entity_id in (self._androidtv_entity, self._power_entity)
if self._intent: and new_state_obj
_LOGGER.debug("Intent cleared: device powered off") and not self._is_power_on()
self._clear_intent() and self._intent
):
_LOGGER.debug("Intent cleared: device powered off")
self._clear_intent()
# Jellyfin state change — check for intent confirmation and fetch media info # Jellyfin state change — check for intent confirmation and fetch media info
if entity_id == self._jellyfin_entity and new_state_obj: if entity_id == self._jellyfin_entity and new_state_obj:
@@ -387,22 +399,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@callback @callback
def _adb_poll_callback(self, _now) -> None: def _adb_poll_callback(self, _now) -> None:
"""Schedule ADB poll as a task.""" """Schedule the ADB poll as a tracked task (cancelled on unload)."""
self.hass.async_create_task(self._adb_poll()) 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: async def _adb_poll(self) -> None:
"""Poll ADB for wake state and media session, then schedule next poll.""" """Poll ADB for wake state and media session, then schedule next poll."""
try: try:
if not self._adb_poller: if not self._adb_poller or self._removed:
return return
# Always check wake state # Always check wake state
prev_awake = self._adb_awake
self._adb_awake = await self._adb_poller.is_awake() self._adb_awake = await self._adb_poller.is_awake()
# Only poll media session when awake and Jellyfin is NOT active if self._adb_awake is None:
jf = self._read(self._jellyfin_entity) # Poll failure (connection dropped, device unreachable) — make
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) # 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: if self._adb_awake:
# Always poll ADB when awake — pass the foreground app # Always poll ADB when awake — pass the foreground app
@@ -414,19 +433,34 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._adb_poller.clear_info() self._adb_poller.clear_info()
self._running_apps = [] self._running_apps = []
# Always update state (wake state may have changed) # Always update state — any ADB-derived value may have changed
if self._adb_awake != prev_awake or self._adb_poller.last_info: self.async_write_ha_state()
self.async_write_ha_state()
except Exception: except Exception:
_LOGGER.debug("ADB poll error", exc_info=True) self._on_adb_poll_failure()
finally: finally:
# Use slow retry if ADB is disconnected, normal interval if connected # Use slow retry if ADB is disconnected, normal interval if connected
if self._adb_enabled: if self._adb_enabled and not self._removed:
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL 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._adb_poll_unsub = async_call_later(
self.hass, interval, self._adb_poll_callback 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."""
info = await fetch_session_media_info( info = await fetch_session_media_info(
@@ -442,11 +476,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED): if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
return True return True
# If we have an active intent for play/pause, media is conceptually active # If we have an active intent for play/pause, media is conceptually active
if self._intent and self._intent.target_state in ( return bool(
STATE_PLAYING, STATE_PAUSED self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED)
): )
return True
return False
# ── State ── # ── State ──
@@ -705,7 +737,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
async def async_mute_volume(self, mute): async def async_mute_volume(self, mute):
await self._cmd("volume_mute", is_volume_muted=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.""" """Send a media command. Uses ADB dispatch for non-Jellyfin apps when available."""
if intent_state: if intent_state:
self._set_intent(ha_service, intent_state) self._set_intent(ha_service, intent_state)
+2 -2
View File
@@ -4,8 +4,8 @@ import logging
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import HomeAssistant, callback, Event from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo 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_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event