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
|
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
|
||||||
|
|
||||||
Reference in new issue
Block a user