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:
1 parent
61aca9d413
commit
728b7ccbf9
1 file changed
+164
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user