feat(media_player): protocol-first command routing with ADB + legacy fallbacks, play_media(channel/app/url) per §7 with digit task-cancel guard, volume readout from protocol push, is_on tier in the power ladder

This commit is contained in:
James Mackay committed 2026-10-08 09:43:34 +01:00
1 parent 61aca9d413
commit 728b7ccbf9
1 file changed
+164 -8
+164 -8
View File
@@ -16,6 +16,7 @@ Intent tracking bridges the gap between instant commands and slow Jellyfin polli
4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth
"""
import asyncio
import logging
import time
@@ -40,13 +41,17 @@ from .const import (
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
CHANNEL_DIGIT_DELAY,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_CHANNEL_SEND_ENTER,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DOMAIN,
PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS,
)
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .jellyfin import fetch_session_media_info
@@ -69,6 +74,7 @@ _BASE_FEATURES = (
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY_MEDIA
)
_PLAYBACK_FEATURES = (
@@ -117,6 +123,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# (connection.py) — this entity is a thin callback-view over its store.
self._manager: ShieldConnectionManager | None = None
self._manager_unsub: CALLBACK_TYPE | None = None
# In-flight channel digit entry (play_media(channel) — §7); a new
# play_media cancels the previous sequence (core task-cancel guard).
self._channel_task = None
# Entity-owned background tasks (intent retries, media-info fetch);
# cancelled on unload. The manager owns the socket — never touch it
# here directly.
@@ -259,6 +268,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
intent.command, jf_state, intent.target_state, intent.retry_count,
)
if (
intent.via_protocol
and intent.protocol_key
and self._manager is not None
):
# The command was served by the protocol key push — retry there.
self._track_task(
self._manager.send_protocol_key(intent.protocol_key)
)
elif (
intent.via_adb
and intent.adb_command
and self._manager is not None
@@ -358,6 +376,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
task = self.hass.async_create_task(coro)
self._entity_tasks.add(task)
task.add_done_callback(self._entity_tasks.discard)
return task
async def _fetch_media_info(self, entity_picture: str) -> None:
"""Read stream info from the HA Jellyfin integration's coordinator."""
@@ -387,12 +406,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
ADB is the authority — it knows if the device is awake or asleep.
The plug being "on" just means the outlet has power (standby).
"""
# 1. ADB wake state — most accurate (exact mWakefulness parse)
# 1. Protocol is_on push — fastest truth when the transport is live
protocol_is_on = self._manager.is_on if self._manager else None
if protocol_is_on is not None:
return protocol_is_on
# 2. ADB wake state — exact mWakefulness parse
adb_awake = self._manager.awake if self._manager else None
if adb_awake is not None:
return adb_awake
# 2. Power entity — only trust "off" (plug pulled = definitely off)
# 3. Power entity — only trust "off" (plug pulled = definitely off)
if self._power_entity:
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
@@ -411,7 +435,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
power = self._is_power_on()
if power is False:
return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot)
if self._manager is not None and self._manager.is_on is False:
return MediaPlayerState.OFF # protocol says powered off
# ADB says asleep — standby (yellow dot)
return MediaPlayerState.IDLE
if power is None:
return MediaPlayerState.IDLE
@@ -501,6 +528,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def media_image_url(self):
return self._jf_attr("entity_picture") if self._jf_active() else None
@property
def volume_level(self) -> float | None:
"""Protocol volume push readout (C-VOL — level only, no VOLUME_SET)."""
if self._manager is None:
return None
info = self._manager.volume_info
if info is None:
return None
level, volume_max, _muted = info
try:
if volume_max and volume_max > 0:
return level / volume_max
except (TypeError, ValueError):
pass
return None
@property
def is_volume_muted(self) -> bool | None:
"""Real mute state from the protocol push (was unknowable pre-Phase 2)."""
if self._manager is None:
return None
info = self._manager.volume_info
return info[2] if info is not None else None
def _get_now_playing(self) -> dict | None:
if not self._jf_active():
return None
@@ -629,18 +680,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
await self._cmd("turn_off")
async def async_volume_up(self):
await self._cmd("volume_up")
await self._remote_key_cmd("volume_up", "volume_up")
async def async_volume_down(self):
await self._cmd("volume_down")
await self._remote_key_cmd("volume_down", "volume_down")
async def async_mute_volume(self, mute):
await self._cmd("volume_mute", is_volume_muted=mute)
"""Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL)."""
await self._remote_key_cmd("volume_mute", "mute")
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
if (
protocol_key
and self._manager is not None
and await self._manager.send_protocol_key(protocol_key)
):
return
if self._manager is not None and await self._manager.send_media_command(adb_key):
return
# Transitional: external androidtv_remote entity (removed in Phase 4)
await self._cmd(remote_key)
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 over the fastest available command path.
Order (Phase 2): protocol key push → ADB keyevent → the external
androidtv_remote entity (transitional until Phase 4 removes it).
"""
if intent_state:
self._set_intent(ha_service, intent_state)
self.async_write_ha_state()
@@ -649,6 +719,19 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
# 1. Protocol key push (fast; works whenever the transport is up)
protocol_key = PROTOCOL_MEDIA_KEYS.get(ha_service)
if (
protocol_key
and self._manager is not None
and await self._manager.send_protocol_key(protocol_key)
):
if self._intent is not None:
self._intent.via_protocol = True
self._intent.protocol_key = protocol_key
return
# 2. ADB keyevent (socket-independent; exact same commands as before)
if self._manager and self._manager.connected and not jf_active:
success = await self._manager.send_media_command(adb_command)
if success:
@@ -659,7 +742,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._intent.adb_command = adb_command
return
# Fall back to Android TV Remote
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
await self._cmd(ha_service)
async def async_media_play(self):
@@ -683,3 +766,76 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_LOGGER.warning("ADB not available for bring_to_foreground")
return False
return await self._manager.bring_to_foreground(package)
# ── play_media (§7): channel digits, app/url launch ──
async def async_play_media(self, media_type: str, media_id: str) -> None:
"""Channel selection by digit number; app/url launches (C-LAUNCH).
Channel path replicates HA core androidtv_remote exactly: digits only,
configurable inter-digit delay (default 0.1 s), no ENTER — and the
padded prior in-flight channel sequence is cancelled first.
"""
if media_type == "channel":
digits = [c for c in media_id if c.isdigit()]
if not digits:
_LOGGER.warning("play_media(channel): no digits in %r", media_id)
return
self._cancel_inflight_channel()
self._channel_task = self._track_task(
self._channel_entry_sequence(digits)
)
return
if media_type in ("app", "url"):
if self._manager is not None and await self._manager.launch_app(media_id):
return
if (
media_type == "app"
and self._manager is not None
and await self._manager.bring_to_foreground(media_id)
):
# Fallback: monkey launch of the bare package (no deep links).
return
_LOGGER.warning(
"play_media(%s): could not launch %s — no available transport",
media_type,
media_id,
)
return
_LOGGER.warning(
"play_media: unsupported media type %r (supported: channel/app/url)"
" — nothing sent",
media_type,
)
def _cancel_inflight_channel(self) -> None:
task = self._channel_task
if task is not None and not task.done():
task.cancel()
self._channel_task = None
async def _channel_entry_sequence(self, digits: list[str]) -> None:
delay = self._entry.options.get("channel_digit_delay", CHANNEL_DIGIT_DELAY)
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
try:
for digit in digits:
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
served = False
if self._manager is not None:
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
if not served:
served = await self._manager.send_digit_key(digit)
if not served:
await self._cmd(
"play_media",
media={"media_content_id": digit, "media_content_type": "channel"},
)
await asyncio.sleep(delay)
if send_enter:
_LOGGER.debug("play_media(channel): optional ENTER key sent")
await self._manager.send_protocol_key("KEYCODE_ENTER")
except asyncio.CancelledError:
raise