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:
1 parent
359b96ef9c
commit
d3afda0a25
7 files changed
+82
-41
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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +91,14 @@ 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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +288,12 @@ 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
|
||||||
|
and not self._is_power_on()
|
||||||
|
and self._intent
|
||||||
|
):
|
||||||
_LOGGER.debug("Intent cleared: device powered off")
|
_LOGGER.debug("Intent cleared: device powered off")
|
||||||
self._clear_intent()
|
self._clear_intent()
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user