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 4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth
""" """
import asyncio
import logging import logging
import time import time
@@ -40,13 +41,17 @@ from .const import (
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,
CHANNEL_DIGIT_DELAY,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_CHANNEL_SEND_ENTER,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
DOMAIN, DOMAIN,
PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS,
) )
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .jellyfin import fetch_session_media_info from .jellyfin import fetch_session_media_info
@@ -69,6 +74,7 @@ _BASE_FEATURES = (
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY_MEDIA
) )
_PLAYBACK_FEATURES = ( _PLAYBACK_FEATURES = (
@@ -117,6 +123,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# (connection.py) — this entity is a thin callback-view over its store. # (connection.py) — this entity is a thin callback-view over its store.
self._manager: ShieldConnectionManager | None = None self._manager: ShieldConnectionManager | None = None
self._manager_unsub: CALLBACK_TYPE | 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); # Entity-owned background tasks (intent retries, media-info fetch);
# cancelled on unload. The manager owns the socket — never touch it # cancelled on unload. The manager owns the socket — never touch it
# here directly. # here directly.
@@ -259,6 +268,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
intent.command, jf_state, intent.target_state, intent.retry_count, intent.command, jf_state, intent.target_state, intent.retry_count,
) )
if ( 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 intent.via_adb
and intent.adb_command and intent.adb_command
and self._manager is not None and self._manager is not None
@@ -358,6 +376,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
task = self.hass.async_create_task(coro) task = self.hass.async_create_task(coro)
self._entity_tasks.add(task) self._entity_tasks.add(task)
task.add_done_callback(self._entity_tasks.discard) task.add_done_callback(self._entity_tasks.discard)
return task
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."""
@@ -387,12 +406,17 @@ 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 (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 adb_awake = self._manager.awake if self._manager else None
if adb_awake is not None: if adb_awake is not None:
return adb_awake 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: if self._power_entity:
power_state = self._read(self._power_entity) power_state = self._read(self._power_entity)
if power_state and power_state.state == "off": if power_state and power_state.state == "off":
@@ -411,7 +435,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
power = self._is_power_on() power = self._is_power_on()
if power is False: 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: if power is None:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
@@ -501,6 +528,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def media_image_url(self): def media_image_url(self):
return self._jf_attr("entity_picture") if self._jf_active() else None 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: def _get_now_playing(self) -> dict | None:
if not self._jf_active(): if not self._jf_active():
return None return None
@@ -629,18 +680,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
await self._cmd("turn_off") await self._cmd("turn_off")
async def async_volume_up(self): 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): 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): 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( async def _media_cmd(
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = 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: if intent_state:
self._set_intent(ha_service, intent_state) self._set_intent(ha_service, intent_state)
self.async_write_ha_state() self.async_write_ha_state()
@@ -649,6 +719,19 @@ 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)
# 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: if self._manager and self._manager.connected and not jf_active:
success = await self._manager.send_media_command(adb_command) success = await self._manager.send_media_command(adb_command)
if success: if success:
@@ -659,7 +742,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._intent.adb_command = adb_command self._intent.adb_command = adb_command
return return
# Fall back to Android TV Remote # 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
await self._cmd(ha_service) await self._cmd(ha_service)
async def async_media_play(self): async def async_media_play(self):
@@ -683,3 +766,76 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_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._manager.bring_to_foreground(package) 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