"""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