913 lines
34 KiB
Python
913 lines
34 KiB
Python
"""Media player entity for Nvidia Shield.
|
|
|
|
Phase 2 architecture (docs/MVP_PLAN.md §2): the entry's ShieldConnectionManager
|
|
(connection.py) owns both transports — ADB (adb_shell, state source) and the
|
|
absorbed remote protocol (androidtvremote2, local push) — plus their StateStore;
|
|
this entity is a thin callback-view over it.
|
|
|
|
State architecture:
|
|
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
|
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
|
- Metadata: Jellyfin attributes
|
|
- Commands: protocol key push → ADB keyevent → external androidtv_remote
|
|
(the last tier is transitional; removed in Phase 4)
|
|
|
|
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
|
1. User presses pause → intent recorded → UI shows paused immediately
|
|
2. Command sent over the serving transport → Shield pauses on screen
|
|
3. 10-30s later Jellyfin confirms paused → intent cleared
|
|
4. If Jellyfin doesn't confirm in 30s → retry once on the serving
|
|
transport (protocol/ADB stamped on the intent) → surrender to truth
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from homeassistant.components.media_player import (
|
|
MediaPlayerDeviceClass,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
|
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
|
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_DIGIT_DELAY,
|
|
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
|
|
from .power import discover_power_siblings
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
INTENT_TIMEOUT = 10 # seconds before self-heal fires
|
|
MAX_RETRIES = 1
|
|
|
|
# Map service names to the MediaPlayerState we expect after the command
|
|
SERVICE_TO_STATE = {
|
|
"media_pause": MediaPlayerState.PAUSED,
|
|
"media_play": MediaPlayerState.PLAYING,
|
|
"media_stop": MediaPlayerState.IDLE,
|
|
}
|
|
|
|
_BASE_FEATURES = (
|
|
MediaPlayerEntityFeature.TURN_ON
|
|
| MediaPlayerEntityFeature.TURN_OFF
|
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
)
|
|
|
|
_PLAYBACK_FEATURES = (
|
|
MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
|
|
|
|
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|
|
|
_attr_device_class = MediaPlayerDeviceClass.TV
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
_attr_should_poll = False
|
|
# Overridden as a property below: ADB-health aware
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
self.hass = hass
|
|
self._entry = entry
|
|
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
|
self._unsub_listeners: list = []
|
|
|
|
# Intent tracking
|
|
self._intent: PlaybackIntent | None = None
|
|
self._intent_timer_unsub: CALLBACK_TYPE | None = None
|
|
|
|
# Power monitoring — discovered from power switch entity
|
|
self._power_sensors: dict[str, str] = {}
|
|
|
|
# Jellyfin media stream info (resolution, codec, etc.)
|
|
self._media_info: dict | None = None
|
|
|
|
# ADB-derived state comes from the entry's ShieldConnectionManager
|
|
# (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
|
|
# Last channel entered via play_media(channel) — the foreground
|
|
# tier's title fallback when an app publishes no session (P3-3a).
|
|
self._last_channel: str | None = None
|
|
# Entity-owned background tasks (intent retries, media-info fetch);
|
|
# cancelled on unload. The manager owns the socket — never touch it
|
|
# here directly.
|
|
self._entity_tasks: set = set()
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""The bridge entities stay functional without ADB (passive manager).
|
|
|
|
Unavailable only while an active ADB manager is down — its data is
|
|
unreliable until the watchdog reconnects (t5-F2).
|
|
"""
|
|
manager = getattr(self._entry, "runtime_data", None)
|
|
if not isinstance(manager, ShieldConnectionManager):
|
|
return True
|
|
return manager.connected or manager.awake is not None or not manager.available
|
|
|
|
@property
|
|
def _jellyfin_entity(self) -> str | None:
|
|
return self._entry.options.get(CONF_JELLYFIN_ENTITY) or None
|
|
|
|
@property
|
|
def _receiver_entity(self) -> str | None:
|
|
return self._entry.options.get(CONF_RECEIVER_ENTITY) or None
|
|
|
|
@property
|
|
def _receiver_input(self) -> str | None:
|
|
return self._entry.options.get(CONF_RECEIVER_INPUT) or None
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
|
name=self._entry.title,
|
|
manufacturer="NVIDIA",
|
|
model="Shield TV",
|
|
)
|
|
|
|
@property
|
|
def _power_entity(self) -> str | None:
|
|
return self._entry.options.get(CONF_POWER_ENTITY) or None
|
|
|
|
@property
|
|
def _adb_store_info(self) -> bool:
|
|
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
# Discover power sensor siblings from the power switch
|
|
if self._power_entity:
|
|
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
|
|
|
# Consume the entry's ShieldConnectionManager (thin-view pattern)
|
|
manager = getattr(self._entry, "runtime_data", None)
|
|
if manager is not None:
|
|
self._manager = manager
|
|
self._manager_unsub = manager.register_listener(
|
|
self._on_manager_update
|
|
)
|
|
|
|
entities = [self._androidtv_entity]
|
|
if self._jellyfin_entity:
|
|
entities.append(self._jellyfin_entity)
|
|
if self._power_entity:
|
|
entities.append(self._power_entity)
|
|
self._unsub_listeners.append(
|
|
async_track_state_change_event(
|
|
self.hass, entities, self._on_source_change
|
|
)
|
|
)
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
for unsub in self._unsub_listeners:
|
|
unsub()
|
|
self._unsub_listeners.clear()
|
|
self._clear_intent()
|
|
if self._manager_unsub:
|
|
self._manager_unsub()
|
|
self._manager_unsub = None
|
|
# Cancel in-flight entity tasks so they cannot outlive the entity.
|
|
for task in self._entity_tasks:
|
|
task.cancel()
|
|
self._entity_tasks.clear()
|
|
|
|
|
|
# ── Intent management ──
|
|
|
|
def _set_intent(self, service: str, target: MediaPlayerState) -> None:
|
|
"""Record a new playback intent. Replaces any existing intent."""
|
|
# Cancel existing timer
|
|
if self._intent_timer_unsub:
|
|
self._intent_timer_unsub()
|
|
|
|
# Capture current Jellyfin state before the intent
|
|
jf = self._read(self._jellyfin_entity)
|
|
prior = jf.state if jf else None
|
|
|
|
self._intent = PlaybackIntent.create(
|
|
target_state=target.value,
|
|
command=service,
|
|
prior_state=prior,
|
|
)
|
|
|
|
# Schedule self-heal timeout
|
|
self._intent_timer_unsub = async_call_later(
|
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
|
)
|
|
|
|
_LOGGER.debug("Intent set: %s → %s (prior JF: %s)", service, target, prior)
|
|
|
|
def _clear_intent(self) -> None:
|
|
"""Clear the pending intent and its timer."""
|
|
if self._intent_timer_unsub:
|
|
self._intent_timer_unsub()
|
|
self._intent_timer_unsub = None
|
|
self._intent = None
|
|
|
|
@callback
|
|
def _on_intent_timeout(self, _now) -> None:
|
|
"""Self-heal: intent wasn't confirmed by Jellyfin in time."""
|
|
intent = self._intent
|
|
if intent is None:
|
|
return
|
|
|
|
jf = self._read(self._jellyfin_entity)
|
|
jf_state = jf.state if jf else None
|
|
|
|
# Check if Jellyfin caught up during the timeout window
|
|
if jf_state == intent.target_state:
|
|
_LOGGER.debug("Intent %s confirmed at timeout check", intent.command)
|
|
self._clear_intent()
|
|
self.async_write_ha_state()
|
|
return
|
|
|
|
# Retry once
|
|
if intent.retry_count < MAX_RETRIES:
|
|
intent.retry_count += 1
|
|
intent.created_at = time.monotonic()
|
|
_LOGGER.warning(
|
|
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
|
|
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
|
|
and self._manager.connected
|
|
):
|
|
# The command was originally served by ADB — retry over ADB.
|
|
self._track_task(
|
|
self._manager.send_media_command(intent.adb_command)
|
|
)
|
|
else:
|
|
self._track_task(
|
|
self._cmd(intent.command)
|
|
)
|
|
self._intent_timer_unsub = async_call_later(
|
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
|
)
|
|
return
|
|
|
|
# Exhausted retries — surrender to Jellyfin truth
|
|
_LOGGER.error(
|
|
"Intent %s failed after %d retries (JF=%s), falling back to truth",
|
|
intent.command, MAX_RETRIES, jf_state,
|
|
)
|
|
self._clear_intent()
|
|
self.async_write_ha_state()
|
|
|
|
# ── State change listener ──
|
|
|
|
@callback
|
|
def _on_source_change(self, event: Event) -> None:
|
|
"""React to state changes in androidtv or Jellyfin."""
|
|
entity_id = event.data.get("entity_id")
|
|
new_state_obj = event.data.get("new_state")
|
|
|
|
# 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
|
|
and not self._is_power_on()
|
|
and self._intent
|
|
):
|
|
_LOGGER.debug("Intent cleared: device powered off")
|
|
self._clear_intent()
|
|
|
|
# Jellyfin state change — check for intent confirmation and fetch media info
|
|
if entity_id == self._jellyfin_entity and new_state_obj:
|
|
new_jf = new_state_obj.state
|
|
|
|
# Intent confirmation
|
|
if self._intent:
|
|
intent = self._intent
|
|
if new_jf == intent.target_state:
|
|
_LOGGER.debug("Intent %s confirmed by Jellyfin", intent.command)
|
|
self._clear_intent()
|
|
elif new_jf not in (
|
|
intent.prior_state, "unavailable", "unknown", None
|
|
):
|
|
_LOGGER.debug(
|
|
"Intent %s overridden by external action (JF=%s)",
|
|
intent.command, new_jf,
|
|
)
|
|
self._clear_intent()
|
|
|
|
# Fetch media stream info when playing
|
|
if new_jf in (STATE_PLAYING, STATE_PAUSED):
|
|
entity_picture = new_state_obj.attributes.get("entity_picture", "")
|
|
self._track_task(
|
|
self._fetch_media_info(entity_picture)
|
|
)
|
|
else:
|
|
self._media_info = None
|
|
|
|
|
|
# Always re-evaluate state
|
|
self.async_write_ha_state()
|
|
|
|
# ── Helpers ──
|
|
|
|
def _read(self, entity_id: str | None):
|
|
if not entity_id:
|
|
return None
|
|
return self.hass.states.get(entity_id)
|
|
|
|
@callback
|
|
def _on_manager_update(self) -> None:
|
|
"""Connection manager's store changed — re-evaluate our state."""
|
|
self.async_write_ha_state()
|
|
|
|
def _track_task(self, coro) -> None:
|
|
"""Dispatch an entity background task; cancelled on unload.
|
|
|
|
Entity-owned tasks (intent retries, media-info fetch) must never go
|
|
through a bare hass.async_create_task: they would survive removal
|
|
(reviewer finding F2). The connection manager owns its own tasks and
|
|
the socket — nothing here touches it directly.
|
|
"""
|
|
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."""
|
|
info = await fetch_session_media_info(
|
|
self.hass, self._jellyfin_entity, self._entry.title
|
|
)
|
|
if info != self._media_info:
|
|
self._media_info = info
|
|
self.async_write_ha_state()
|
|
|
|
def _jf_active(self) -> bool:
|
|
"""Is Jellyfin reporting active playback?"""
|
|
jf = self._read(self._jellyfin_entity)
|
|
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
|
|
return True
|
|
# If we have an active intent for play/pause, media is conceptually active
|
|
return bool(
|
|
self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED)
|
|
)
|
|
|
|
# ── State ──
|
|
|
|
def _is_power_on(self) -> bool | None:
|
|
"""Determine if the Shield is powered on.
|
|
|
|
Priority: ADB wake state > power entity plug off > unknown.
|
|
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. 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
|
|
|
|
# 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":
|
|
return False
|
|
|
|
return None
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState:
|
|
# Check if plug is off first — truly off
|
|
if self._power_entity:
|
|
power_state = self._read(self._power_entity)
|
|
if power_state and power_state.state == "off":
|
|
return MediaPlayerState.OFF
|
|
|
|
power = self._is_power_on()
|
|
|
|
if power is False:
|
|
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
|
|
|
|
# Tier 1: Active intent overrides Jellyfin
|
|
if self._intent is not None:
|
|
return MediaPlayerState(self._intent.target_state)
|
|
|
|
# Tier 2: Jellyfin is authoritative
|
|
jf = self._read(self._jellyfin_entity)
|
|
if jf:
|
|
if jf.state == STATE_PLAYING:
|
|
return MediaPlayerState.PLAYING
|
|
if jf.state == STATE_PAUSED:
|
|
return MediaPlayerState.PAUSED
|
|
|
|
# Tier 3: ADB media session (non-Jellyfin apps)
|
|
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
|
if adb_info:
|
|
if adb_info.playing:
|
|
return MediaPlayerState.PLAYING
|
|
if adb_info.paused:
|
|
return MediaPlayerState.PAUSED
|
|
|
|
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
|
|
# current_app push, else the ADB mResumedActivity parse) but publishes
|
|
# no media session. The device is still ON; the now-playing tier
|
|
# carries the last-known/channel/audio-hint identity.
|
|
if self._manager is not None and self._manager.foreground_app:
|
|
return MediaPlayerState.ON
|
|
|
|
# Tier 4: On but no media
|
|
return MediaPlayerState.ON
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
if self._jf_active():
|
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
|
# ADB media session also supports playback controls
|
|
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
|
if adb_info and (adb_info.playing or adb_info.paused):
|
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
|
return _BASE_FEATURES
|
|
|
|
# ── Metadata from Jellyfin ──
|
|
|
|
def _jf_attr(self, key: str):
|
|
jf = self._read(self._jellyfin_entity)
|
|
return jf.attributes.get(key) if jf else None
|
|
|
|
@property
|
|
def app_id(self):
|
|
"""Android TV entity first; standalone setups fall back to the
|
|
protocol current-app push (raw package — both stamp the store)."""
|
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
|
if atv is not None:
|
|
app_id = atv.attributes.get("app_id")
|
|
if app_id:
|
|
return app_id
|
|
if self._manager is not None:
|
|
return self._manager.current_app
|
|
return None
|
|
|
|
@property
|
|
def app_name(self):
|
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
|
if atv is not None:
|
|
app_name = atv.attributes.get("app_name")
|
|
if app_name:
|
|
return app_name
|
|
if self._manager is not None:
|
|
# Name resolution is deferred app-map work; the package is truth.
|
|
return self._manager.current_app
|
|
return None
|
|
|
|
@property
|
|
def media_content_type(self):
|
|
return self._jf_attr("media_content_type") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_title(self):
|
|
return self._jf_attr("media_title") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_series_title(self):
|
|
return self._jf_attr("media_series_title") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_season(self):
|
|
return self._jf_attr("media_season") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_episode(self):
|
|
return self._jf_attr("media_episode") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_duration(self):
|
|
return self._jf_attr("media_duration") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_position(self):
|
|
return self._jf_attr("media_position") if self._jf_active() else None
|
|
|
|
@property
|
|
def media_position_updated_at(self):
|
|
return self._jf_attr("media_position_updated_at") if self._jf_active() else None
|
|
|
|
@property
|
|
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
|
|
jf = self._read(self._jellyfin_entity)
|
|
if not jf:
|
|
return None
|
|
attrs = jf.attributes
|
|
np = {"state": self.state.value, "title": attrs.get("media_title"),
|
|
"media_type": attrs.get("media_content_type")}
|
|
for src, dst in [("media_series_title", "series"), ("media_season", "season"),
|
|
("media_episode", "episode"), ("media_duration", "duration"),
|
|
("media_position", "position"),
|
|
("media_position_updated_at", "position_updated_at")]:
|
|
v = attrs.get(src)
|
|
if v is not None:
|
|
np[dst] = v
|
|
if attrs.get("entity_picture"):
|
|
np["thumbnail"] = attrs["entity_picture"]
|
|
if self._media_info:
|
|
np["media_info"] = self._media_info
|
|
return np
|
|
|
|
def _get_adb_now_playing(self) -> dict | None:
|
|
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
|
|
info = self._manager.best_session(self.app_id) if self._manager else None
|
|
if not info:
|
|
return None
|
|
if not self._adb_store_info:
|
|
# Privacy mode: show that something is playing but not what
|
|
if info.playing or info.paused:
|
|
return {
|
|
"state": "playing" if info.playing else "paused",
|
|
"title": self.app_name or self.app_id or "Playing",
|
|
"source": "media_session",
|
|
}
|
|
return None
|
|
|
|
if not info.title:
|
|
return None
|
|
np = {
|
|
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
|
|
"title": info.title,
|
|
"source": "media_session",
|
|
}
|
|
if info.artist:
|
|
np["artist"] = info.artist
|
|
if info.album:
|
|
np["album"] = info.album
|
|
return np
|
|
|
|
def _get_app_now_playing(self) -> dict | None:
|
|
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
|
|
no media session (TiviMate, R2). Title = last known state, else the
|
|
last channel entered, else the zero-cost audio-playback hint.
|
|
"""
|
|
manager = self._manager
|
|
if manager is None:
|
|
return None
|
|
foreground = manager.foreground_app
|
|
if not foreground:
|
|
return None
|
|
|
|
state = "idle"
|
|
title = None
|
|
info = manager.last_known_session(foreground)
|
|
if info is not None and (info.playing or info.paused):
|
|
state = "playing" if info.playing else "paused"
|
|
# Privacy mode: identity stays the app, never the media title
|
|
title = None if not self._adb_store_info else info.title
|
|
if title is None:
|
|
title = self._last_channel
|
|
if title is None and not self._adb_store_info:
|
|
title = self.app_name or self.app_id or foreground
|
|
if title is None and foreground in manager.audio_playback:
|
|
# E hint (zero round-trip cost): audio was recently played
|
|
title = "Media recently active"
|
|
if title is None:
|
|
return None # bare foreground app — the state stays ON regardless
|
|
return {"state": state, "title": title, "source": "app"}
|
|
|
|
def _get_power_data(self) -> dict | None:
|
|
"""Read current values from discovered power sensors."""
|
|
if not self._power_sensors:
|
|
return None
|
|
data = {}
|
|
for key, entity_id in self._power_sensors.items():
|
|
if key == "switch":
|
|
continue
|
|
state = self._read(entity_id)
|
|
if state and state.state not in ("unavailable", "unknown"):
|
|
try:
|
|
data[key] = float(state.state)
|
|
except (ValueError, TypeError):
|
|
data[key] = state.state
|
|
return data if data else None
|
|
|
|
def _get_running_apps(self) -> list[dict] | None:
|
|
"""Build a list of running apps from ADB recent tasks + media sessions."""
|
|
if not self._manager:
|
|
return None
|
|
apps = self._manager.running_apps
|
|
if not apps:
|
|
return None
|
|
if not self._adb_store_info:
|
|
return None
|
|
return running_apps_from_tasks(
|
|
apps,
|
|
self._manager.active_sessions,
|
|
foreground_package=self.app_id,
|
|
last_known=self._manager.last_known_map(),
|
|
)
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
|
# then the foreground tier (P3-3a).
|
|
now_playing = self._get_now_playing()
|
|
if not now_playing:
|
|
now_playing = self._get_adb_now_playing()
|
|
if not now_playing:
|
|
now_playing = self._get_app_now_playing()
|
|
|
|
attrs = {"app_id": self.app_id, "app_name": self.app_name,
|
|
ATTR_MEDIA_DAG_ROLE: "source",
|
|
ATTR_MEDIA_DAG_NOW_PLAYING: now_playing}
|
|
if self._receiver_entity:
|
|
attrs[ATTR_MEDIA_DAG_RECEIVER_ENTITY] = self._receiver_entity
|
|
if self._receiver_input:
|
|
attrs[ATTR_MEDIA_DAG_RECEIVER_INPUT] = self._receiver_input
|
|
if self._jellyfin_entity:
|
|
attrs[ATTR_MEDIA_DAG_JELLYFIN_ENTITY] = self._jellyfin_entity
|
|
|
|
# Running apps from ADB media sessions
|
|
running_apps = self._get_running_apps()
|
|
if running_apps:
|
|
attrs[ATTR_MEDIA_DAG_RUNNING_APPS] = running_apps
|
|
|
|
# Power monitoring
|
|
if self._power_entity:
|
|
attrs["power_switch"] = self._power_entity
|
|
power_data = self._get_power_data()
|
|
if power_data:
|
|
attrs["power_monitoring"] = power_data
|
|
|
|
# Intent state for debugging
|
|
if self._intent:
|
|
attrs["_intent"] = self._intent.to_debug_dict()
|
|
return attrs
|
|
|
|
# ── Commands — ALL go to androidtv_remote ──
|
|
|
|
async def _cmd(self, service: str, **extra) -> None:
|
|
data = {"entity_id": self._androidtv_entity, **extra}
|
|
await self.hass.services.async_call("media_player", service, data)
|
|
|
|
async def async_turn_on(self):
|
|
await self._cmd("turn_on")
|
|
|
|
async def async_turn_off(self):
|
|
await self._cmd("turn_off")
|
|
|
|
async def async_volume_up(self):
|
|
await self._remote_key_cmd("volume_up", "volume_up")
|
|
|
|
async def async_volume_down(self):
|
|
await self._remote_key_cmd("volume_down", "volume_down")
|
|
|
|
async def async_mute_volume(self, 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 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()
|
|
|
|
# Use ADB media dispatch when ADB is active and Jellyfin isn't playing
|
|
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:
|
|
# The ADB path served this command — the self-heal retry must
|
|
# also go over ADB, not back to the remote.
|
|
if self._intent is not None:
|
|
self._intent.via_adb = True
|
|
self._intent.adb_command = adb_command
|
|
return
|
|
|
|
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
|
|
await self._cmd(ha_service)
|
|
|
|
async def async_media_play(self):
|
|
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
|
|
|
async def async_media_pause(self):
|
|
await self._media_cmd("media_pause", "pause", MediaPlayerState.PAUSED)
|
|
|
|
async def async_media_stop(self):
|
|
await self._media_cmd("media_stop", "stop", MediaPlayerState.IDLE)
|
|
|
|
async def async_media_next_track(self):
|
|
await self._media_cmd("media_next_track", "next")
|
|
|
|
async def async_media_previous_track(self):
|
|
await self._media_cmd("media_previous_track", "previous")
|
|
|
|
async def async_bring_to_foreground(self, package: str) -> bool:
|
|
"""Bring a specific app to the foreground via ADB."""
|
|
if self._manager is None or not self._manager.connected:
|
|
_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._last_channel = "".join(digits)
|
|
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(CONF_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
|
|
and self._manager is not None
|
|
and not await self._manager.send_protocol_key("KEYCODE_ENTER")
|
|
):
|
|
# ENTER fallback chain: protocol key → ADB keyevent 66.
|
|
# (No legacy-entity tier: the external androidtv_remote has
|
|
# no ENTER service to delegate to.)
|
|
await self._manager.send_enter_key()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|