The user reads attributes as YAML — "These could do with being separate attributes rather than an object in one?" — so the nested `media_dag_now_playing` object is gone and every field it carried is its own top-level attribute: now_playing_via, now_playing_state, now_playing_title, now_playing_artist, now_playing_album, now_playing_media_type, now_playing_series, now_playing_season, now_playing_episode, now_playing_duration, now_playing_position, now_playing_position_updated_at, now_playing_thumbnail, now_playing_media_info Rules the surface now holds to: - an unknown field is OMITTED, never published as None (`title: null` inside the object was exactly the noise this removes); 0 and False are real values - a device reading `off` publishes no now-playing row at all — a Jellyfin session that has gone stale must not put a title beside a device that is off — and `on` (nothing reported) publishes none either, because there is no reading - `now_playing_state` IS the entity's own state, so the ladder stays the single answer even while the foreground tier's last-known bridge serves a title - the tier indicator is `now_playing_via` (`jellyfin` / `media_session` / `app`), NOT `source`: on a media_player `source`/`source_list` mean the selected app input (`Netflix`, `TiviMate`, …) and they still do — a tier named `source` is what made the reading look like media metadata from somewhere else. The internal reading key is renamed `via` as well, and the Jellyfin tier now names itself too - every published name is declared unrecorded (G12): these change every poll COMPATIBILITY: `media_dag_now_playing` is removed, not deprecated, and no alias is kept — a nested copy would double every poll's payload. README documents the exact names, these rules, the sensor's own flat set and a one-line template migration. sensor.py: the Jellyfin now-playing sensor follows the same no-None rule (`title` and `media_type` used to be published as null there). Tests: 17 new cases pin the flattening, the tier rename, the five states, the off/on gate, the playing->idle transition, the last-known bridge, privacy mode and agreement with the Current App sensor; the tier-dict tests follow `via`.
1767 lines
75 KiB
Python
1767 lines
75 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, published as separate top-level
|
|
``now_playing_*`` attributes — t65 flattened the one nested
|
|
``media_dag_now_playing`` object into them, and ``now_playing_via`` names the
|
|
tier that produced the reading (``jellyfin`` / ``media_session`` / ``app``)
|
|
- Commands: a configured per-command script (t44) → protocol key push → ADB
|
|
keyevent. The external androidtv_remote entity tier that used to back this is
|
|
gone — t41 removed it with the entity — so a command has exactly one owner.
|
|
|
|
State ladder (t55) — what ``state`` reports, in order. The vocabulary is
|
|
EXACTLY ``{playing, paused, buffering, idle, off, on}``; each step is the first
|
|
source that has something to say, and the earlier steps always win:
|
|
|
|
1. OFF — the configured power entity reads "off", or the protocol's
|
|
is_on push is False (the device reporting its own power
|
|
state). Both OUTRANK media: a stale Jellyfin session must not
|
|
resurrect playback on a device that is off.
|
|
2. intent — a play/pause/stop this entity just sent (optimistic UI until
|
|
a slow Jellyfin poll confirms it; self-heal in 10 s).
|
|
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
|
|
4. session — the ADB media session's PlaybackState, including BUFFERING
|
|
(Android state 6, inferred from the dump's line shape rather
|
|
than observed on hardware). State 3 → playing, 2 → paused.
|
|
5. IDLE — ADB says the device is asleep (standby), OR the device is
|
|
powered on (protocol is_on True / ADB awake / the plug is on)
|
|
and NOTHING is playing. "Powered on, app open, nothing
|
|
playing" is IDLE for exactly this reason.
|
|
6. ON — NOTHING reported a POWER OR PLAYBACK FACT — not "no data" (t58
|
|
UNIT 0, the t59 F2 ruling, which this wording follows). ON is
|
|
the "we cannot know" state and the only situation it may
|
|
describe, so every source that ANSWERS — even to say "not
|
|
playing" — takes the ladder to IDLE at worst, never here:
|
|
* a configured Jellyfin entity in ANY state, including idle /
|
|
off / standby / unavailable (and it reports "not playing"
|
|
without ever masking a live media session, because the
|
|
playing/paused tiers above still win);
|
|
* a live media session that carries no playback flag, i.e. the
|
|
app publishes a session but is not playing, paused or
|
|
buffering;
|
|
* a bare foreground app — the protocol's current-app push or
|
|
the ADB mResumedActivity parse answered;
|
|
* a configured power entity, whatever it currently says;
|
|
* protocol is_on / ADB awake, plugged or not.
|
|
ON survives for an entry where NONE of those exists: no
|
|
transport data, no power entity, no Jellyfin entity, no session,
|
|
no foreground app. It must never mask a playing/paused/buffering
|
|
source — the rule that fixes the live "On overrides my playing
|
|
states" report.
|
|
|
|
|
|
|
|
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
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.components.media_player import (
|
|
BrowseMedia,
|
|
MediaClass,
|
|
MediaPlayerDeviceClass,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
|
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
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_RECEIVER_ENTITY,
|
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
|
ATTR_MEDIA_DAG_ROLE,
|
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
|
CHANNEL_DIGIT_DELAY,
|
|
CONF_ADB_COMMAND_ENABLED,
|
|
CONF_ADB_STORE_APP_INFO,
|
|
CONF_APP_ICON_MAP,
|
|
CONF_APP_NAME_MAP,
|
|
CONF_CHANNEL_DIGIT_DELAY,
|
|
CONF_CHANNEL_SEND_ENTER,
|
|
CONF_JELLYFIN_ENTITY,
|
|
CONF_POWER_ENTITY,
|
|
CONF_RECEIVER_ENTITY,
|
|
CONF_RECEIVER_INPUT,
|
|
DEFAULT_APP_DIRECTORY,
|
|
DOMAIN,
|
|
PROTOCOL_MEDIA_KEYS,
|
|
PROTOCOL_REMOTE_KEYS,
|
|
SCRIPT_VAR_APP_NAME,
|
|
SCRIPT_VAR_LEVEL,
|
|
SCRIPT_VAR_MUTED,
|
|
TIVIMATE_PACKAGE,
|
|
command_scripts,
|
|
)
|
|
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,
|
|
}
|
|
|
|
#: t61: the domain a receiver must be in for playback to ENSURE it is on. A
|
|
#: receiver in any other domain (an AVR media_player) keeps its own semantics —
|
|
#: input selection and attribute enrichment — and is never switched here.
|
|
RECEIVER_SWITCH_DOMAIN = "switch"
|
|
|
|
#: t63: the browse root's id and type — the FIX for the live report "Browse media
|
|
#: seems to spin and never finish loading".
|
|
#:
|
|
#: The id must be a real STRING. HA core never serves a node without one: 43
|
|
#: ``BrowseMedia`` constructions across the core media_player platforms all pass a
|
|
#: string (a literal or module constant — arcam_fmj's "root", "apps",
|
|
#: ``BROWSE_ROOT``, ``TUNER_PRESETS_ROOT`` — or "" for a pure container); zero
|
|
#: pass ``None``. The media-browser frontend keys the node it is loading on that
|
|
#: id, so ``media_content_id=None`` left the dialog re-requesting the same node for
|
|
#: ever. "root" is arcam_fmj's own browse-root id, and every requested id — this
|
|
#: one, ``None``, or anything a stale frontend asks for — answers the same node, so
|
|
#: a repeat request is idempotent rather than a loop.
|
|
BROWSE_ROOT_ID = "root"
|
|
|
|
#: The root's content type, and deliberately NOT ``MediaType.APPS``: core pairs
|
|
#: that type (with an "apps" id) with ``children=[…]`` — a folder OF apps — and this
|
|
#: node has no children at all, so the claim invites the frontend to treat an empty
|
|
#: node as an unloaded collection. No ``MediaType`` member means "a directory with
|
|
#: nothing in it", so the node carries the class it already declares, which is also
|
|
#: exactly what ``can_expand=False`` and ``children=[]`` say.
|
|
BROWSE_ROOT_TYPE = str(MediaClass.DIRECTORY)
|
|
|
|
#: t65: the now-playing surface is FLATTENED into separate top-level attributes.
|
|
#:
|
|
#: The user reads attributes as YAML: "These could do with being separate attributes
|
|
#: rather than an object in one?" — the nested ``media_dag_now_playing`` object is
|
|
#: gone, and every field it carried is published on its own, prefixed so it stays
|
|
#: readable beside the entity's other attributes:
|
|
#:
|
|
#: now_playing_via: media_session
|
|
#: now_playing_state: playing
|
|
#: now_playing_title: Sky News
|
|
#: now_playing_artist: ...
|
|
#:
|
|
#: Two rules hold the surface together:
|
|
#:
|
|
#: * **No None.** HA renders a ``None`` attribute as ``attribute: null`` in the
|
|
#: template editor, so an unknown field is OMITTED rather than published — a
|
|
#: reading carries only what the tier actually answered.
|
|
#: * ``now_playing_via`` names the TIER that produced the reading (``jellyfin``,
|
|
#: ``media_session`` or ``app``). It is deliberately NOT ``source``:
|
|
#: ``source``/``source_list`` already mean "the selected app input" on a
|
|
#: media_player, and a tier key called ``source`` is what made the reading look
|
|
#: like media metadata from somewhere else.
|
|
NOW_PLAYING_PREFIX = "now_playing_"
|
|
|
|
#: The tier indicator's attribute name.
|
|
ATTR_NOW_PLAYING_VIA = f"{NOW_PLAYING_PREFIX}via"
|
|
|
|
#: Every field the three tiers can put in a reading (t55/t65). A reading's own key
|
|
#: becomes ``NOW_PLAYING_PREFIX + key`` on the entity, except ``via``.
|
|
NOW_PLAYING_READING_KEYS = (
|
|
"via",
|
|
"state",
|
|
"title",
|
|
"media_type",
|
|
"artist",
|
|
"album",
|
|
"series",
|
|
"season",
|
|
"episode",
|
|
"duration",
|
|
"position",
|
|
"position_updated_at",
|
|
"thumbnail",
|
|
"media_info",
|
|
)
|
|
|
|
|
|
def now_playing_attribute_names() -> frozenset[str]:
|
|
"""The flat now-playing attribute names — the whole published surface (t65)."""
|
|
return frozenset(
|
|
ATTR_NOW_PLAYING_VIA if key == "via" else f"{NOW_PLAYING_PREFIX}{key}"
|
|
for key in NOW_PLAYING_READING_KEYS
|
|
)
|
|
|
|
|
|
def flatten_now_playing(now_playing: dict | None) -> dict:
|
|
"""One tier reading -> separate TOP-LEVEL attributes (t65).
|
|
|
|
Unknown fields are dropped, never published as ``None``: the complaint is
|
|
readability in YAML and templates, and a ``now_playing_title: null`` row is
|
|
exactly the noise this removes. Empty strings go the same way; ``0`` and
|
|
``False`` are real values and stay.
|
|
"""
|
|
if not now_playing:
|
|
return {}
|
|
flat: dict = {}
|
|
for key, value in now_playing.items():
|
|
if value is None or value == "":
|
|
continue
|
|
name = ATTR_NOW_PLAYING_VIA if key == "via" else f"{NOW_PLAYING_PREFIX}{key}"
|
|
flat[name] = value
|
|
return flat
|
|
|
|
|
|
_BASE_FEATURES = (
|
|
MediaPlayerEntityFeature.TURN_ON
|
|
| MediaPlayerEntityFeature.TURN_OFF
|
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
)
|
|
|
|
_PLAYBACK_FEATURES = (
|
|
MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
)
|
|
|
|
|
|
async def async_call_script(
|
|
hass: HomeAssistant, script: str, action: str, variables: dict | None = None
|
|
) -> None:
|
|
"""Call a configured script with variables, surfacing failures (t44/t47).
|
|
|
|
``blocking=True`` so a script that fails surfaces HERE: a command that cannot
|
|
run must be a visible, translated error rather than a silent no-op. Module-level
|
|
so the media player and the remote entity share one implementation.
|
|
"""
|
|
data: dict = {"entity_id": script}
|
|
if variables:
|
|
data["variables"] = variables
|
|
try:
|
|
await hass.services.async_call("script", "turn_on", data, blocking=True)
|
|
except Exception as err: # noqa: BLE001 - re-raised as a user-facing error
|
|
_LOGGER.error("the script for %s (%s) failed: %s", action, script, err)
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="command_script_failed",
|
|
translation_placeholders={
|
|
"action": action,
|
|
"script": script,
|
|
"error": str(err),
|
|
},
|
|
) from err
|
|
|
|
|
|
async def async_run_command_script(
|
|
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
|
|
) -> bool:
|
|
"""Run the script configured for ``command``; True when one handled it (t44)."""
|
|
script = command_scripts(options).get(command)
|
|
if not script:
|
|
return False
|
|
await async_call_script(hass, script, command, variables)
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
|
# P5-1: one-time legacy unique_id migration (t20 F3 closure)
|
|
hass.async_create_task(_migrate_legacy_unique_id(hass, entry))
|
|
|
|
|
|
async def _migrate_legacy_unique_id(hass, entry) -> None:
|
|
"""Migrate legacy androidtv-entity-id unique_ids once (t20 F3).
|
|
|
|
Legacy entries carry the androidtv entity id as unique_id; the one-time
|
|
migration moves it to the protocol MAC when the transport can resolve
|
|
it, else to the config-entry id. After the single update_entry the
|
|
legacy pattern no longer matches, so this never runs twice for an
|
|
entry. No behavior change: the entry's data/options are untouched.
|
|
"""
|
|
# t41: the entity path is gone, so the SHAPE of the stale id is the marker.
|
|
# Legacy entries (pre-t29 G2) carry the androidtv entity id as unique_id;
|
|
# every other shape (a MAC, the config-entry id, a yaml: seed id) is left
|
|
# exactly as it is.
|
|
legacy = entry.unique_id or ""
|
|
if not legacy.startswith("media_player."):
|
|
return
|
|
manager = getattr(entry, "runtime_data", None)
|
|
new_id = None
|
|
if manager is not None:
|
|
info = await manager.async_protocol_device_info()
|
|
if info is not None:
|
|
_name, new_id = info
|
|
if not new_id:
|
|
new_id = entry.entry_id
|
|
# t26 F4: another entry may already own the target MAC — the collision
|
|
# pre-check falls back to the config-entry id instead of re-binding the
|
|
# unique_id to a MAC that could collide with a real second instance.
|
|
other_entries = getattr(
|
|
getattr(hass, "config_entries", None), "entries", []
|
|
) or []
|
|
if any(
|
|
getattr(other, "unique_id", None) == new_id
|
|
for other in other_entries
|
|
if getattr(other, "entry_id", None) != entry.entry_id
|
|
):
|
|
_LOGGER.warning(
|
|
"P5-1: unique_id migration target %r already in use — falling "
|
|
"back to the config-entry id",
|
|
new_id,
|
|
)
|
|
new_id = entry.entry_id
|
|
hass.config_entries.async_update_entry(entry, unique_id=new_id)
|
|
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
|
|
|
|
|
def entity_available(manager: ShieldConnectionManager | None) -> bool:
|
|
"""Is ANY usable transport healthy for this entry? (t40 live install)
|
|
|
|
The single ladder behind ``media_player.available``, ``remote.available``
|
|
and the diagnostics payload, so the three cannot disagree:
|
|
|
|
* the protocol transport is wired and has not reported the device
|
|
unreachable — it serves power/app/volume even when ADB is down;
|
|
* or ADB is connected (live state data);
|
|
* or fresh wake data exists (the poller has spoken at least once);
|
|
* or the manager is passive (no ADB configured at all): nothing to
|
|
transport is not a broken transport.
|
|
|
|
Unavailable only when a CONFIGURED transport is actively down and no usable
|
|
data exists. The t5-F2 rule ("an ADB manager that is down means unreliable
|
|
state") took the whole entity down while a healthy paired protocol transport
|
|
was still serving commands — the reported defect.
|
|
"""
|
|
if not isinstance(manager, ShieldConnectionManager):
|
|
return True
|
|
if manager.protocol_active and manager.is_available is not False:
|
|
return True
|
|
if manager.connected:
|
|
return True
|
|
if manager.awake is not None:
|
|
return True
|
|
return not manager.available
|
|
|
|
|
|
def resolve_app_name(
|
|
package: str | None,
|
|
app_name_map,
|
|
app_label,
|
|
) -> str | None:
|
|
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
|
|
|
|
The single chain behind ``media_player.app_name`` and the Current App
|
|
sensor:
|
|
|
|
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
|
2. the cached device-manifest label (``manager.app_label`` — a cache miss
|
|
enqueues the on-demand fetch and falls through);
|
|
3. the raw package text — a bare or unknown package falls back to itself,
|
|
never to None.
|
|
"""
|
|
if package:
|
|
mapped = (app_name_map or {}).get(package)
|
|
if mapped:
|
|
return mapped
|
|
label = app_label(package) if app_label is not None else None
|
|
if label:
|
|
return label
|
|
return package or None
|
|
|
|
|
|
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._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:
|
|
"""Available while ANY usable transport is healthy (t40 live install).
|
|
|
|
ADB enabled but unreachable no longer takes the entity down: a paired,
|
|
available protocol transport still serves power/app/volume. The ladder
|
|
is shared with the remote and diagnostics (``entity_available``).
|
|
"""
|
|
return entity_available(getattr(self._entry, "runtime_data", None))
|
|
|
|
@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:
|
|
# G10 (t30): when the protocol transport is live and captured, the
|
|
# registry card carries the protocol-pushed identity (name from the
|
|
# device itself, manufacturer 'Google' per the audit ruling); the
|
|
# pre-capture shape stays the entry-title fallback.
|
|
captured = (
|
|
self._manager.protocol_device_data if self._manager is not None else None
|
|
)
|
|
if captured:
|
|
name, manufacturer, model, sw = captured
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
|
name=name or self._entry.title,
|
|
manufacturer=manufacturer,
|
|
model=model,
|
|
sw_version=sw or None,
|
|
)
|
|
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:
|
|
# P4-2a: opt-in as an adb_command target (option default off).
|
|
# Registered at setup; unregistered at unload when the last
|
|
# participating entity goes away.
|
|
if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False):
|
|
# F4: the registry key is computed once and stored on the
|
|
# entity, so add/remove always agree even when entity_id is
|
|
# assigned by HA core after the first read.
|
|
self._adb_command_key = (
|
|
getattr(self, "entity_id", None) or self._attr_unique_id
|
|
)
|
|
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
|
|
|
# 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 = [
|
|
entity_id
|
|
for entity_id in (self._jellyfin_entity, self._power_entity)
|
|
if entity_id
|
|
]
|
|
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()
|
|
# P4-2a/t29 G8: drop this entity's service participation (same key
|
|
# as add). The service itself stays registered (always-on setup).
|
|
|
|
|
|
# ── 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:
|
|
# t41: neither of OUR transports served this command (both were
|
|
# down when it was sent), so there is nothing to re-send. Retrying
|
|
# by delegating to another integration is exactly what this change
|
|
# removes — surrender to the truth instead.
|
|
_LOGGER.warning(
|
|
"Intent %s had no serving transport to retry — falling back "
|
|
"to truth",
|
|
intent.command,
|
|
)
|
|
self._clear_intent()
|
|
self.async_write_ha_state()
|
|
return
|
|
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 Jellyfin or the power switch."""
|
|
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 == 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
|
|
|
|
@staticmethod
|
|
def _session_state(info) -> MediaPlayerState | None:
|
|
"""The MediaPlayerState a media session is reporting, or None (t55).
|
|
|
|
One place decides what a session means, so ``state`` and
|
|
``supported_features`` cannot disagree about it. Buffering is checked
|
|
FIRST: a session refilling its buffer is mid-playback, and state 6 is
|
|
the stock-Android buffering value (an inference from the dump's line
|
|
shape — no captured fixture here carries it), so BUFFERING is the
|
|
more informative of the two if a firmware ever set 6 together with 3.
|
|
"""
|
|
if info is None:
|
|
return None
|
|
if info.buffering:
|
|
return MediaPlayerState.BUFFERING
|
|
if info.playing:
|
|
return MediaPlayerState.PLAYING
|
|
if info.paused:
|
|
return MediaPlayerState.PAUSED
|
|
return None
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState:
|
|
"""The playback state — the ladder is documented in the module docstring.
|
|
|
|
Exactly ``{playing, paused, buffering, idle, off}`` are reported for a
|
|
device we have data about; ``ON`` is reserved for the case where NO
|
|
source reported a POWER OR PLAYBACK FACT — not merely "no transport is
|
|
connected" (t58 UNIT 0, the t59 F2 ruling). A source that exists and
|
|
answers "not playing" (Jellyfin idle/off/standby/unavailable, a live
|
|
media session with no playback flag, a bare foreground app) HAS reported,
|
|
so the ladder stops at IDLE, never ON. ON must also never mask a
|
|
playing/paused/buffering source. A powered-on Shield with an app open but
|
|
nothing playing reads IDLE, not ON (t55 — the live defect users read as
|
|
"On overrides my playing states").
|
|
"""
|
|
# A source that reported a power or playback fact, even a negative one.
|
|
# ON is the "we cannot know" state, so it survives only when nothing at
|
|
# all answered — this flag IS the distinction the ruling turns on.
|
|
reported = False
|
|
|
|
# 1. Explicit OFF. The plug being off is definitive; the protocol's
|
|
# is_on push is the device reporting its own power state. Either
|
|
# one OUTRANKS every media source — a stale Jellyfin session must
|
|
# never resurrect playback on a device that is off.
|
|
if self._power_entity:
|
|
# a CONFIGURED source counts as reporting whatever it says now,
|
|
# which is why an unavailable plug yields IDLE rather than ON
|
|
reported = True
|
|
power_state = self._read(self._power_entity)
|
|
if power_state and power_state.state == "off":
|
|
return MediaPlayerState.OFF
|
|
manager = self._manager
|
|
if manager is not None and manager.is_on is False:
|
|
return MediaPlayerState.OFF
|
|
|
|
# 2. A user-commanded intent is the freshest media truth we have.
|
|
if self._intent is not None:
|
|
return MediaPlayerState(self._intent.target_state)
|
|
|
|
# 3. Jellyfin (slow, but authoritative for its own sessions). A
|
|
# configured entity reports in EVERY state it can be in: playing and
|
|
# paused are truth, and idle / off / standby / unavailable / missing
|
|
# all mean "its session is not playing" — reported, just not
|
|
# playable, so the ladder continues WITHOUT an early return (a silent
|
|
# Jellyfin must never mask a live media session) and IDLE is where it
|
|
# ends (t58 UNIT 0).
|
|
if self._jellyfin_entity:
|
|
reported = True
|
|
jf = self._read(self._jellyfin_entity)
|
|
if jf:
|
|
if jf.state == STATE_PLAYING:
|
|
return MediaPlayerState.PLAYING
|
|
if jf.state == STATE_PAUSED:
|
|
return MediaPlayerState.PAUSED
|
|
|
|
# 4. Live media session (non-Jellyfin apps), buffering included. The
|
|
# session EXISTING is a report even when it carries no playback flag
|
|
# (an idle or paused app still publishes one): that is IDLE, and it is
|
|
# the t58 UNIT 0 case that used to fall through to ON.
|
|
session = manager.best_session(self.app_id) if manager is not None else None
|
|
if session is not None:
|
|
reported = True
|
|
session_state = self._session_state(session)
|
|
if session_state is not None:
|
|
return session_state
|
|
|
|
# 5. ADB standby: powered but asleep — still data (the device
|
|
# answered), so IDLE, never ON. media_player.STANDBY is deprecated
|
|
# (2025-07), so IDLE is the compliant mapping (t22 audit).
|
|
if manager is not None and manager.awake is False:
|
|
return MediaPlayerState.IDLE
|
|
|
|
# 6. The device is on (protocol is_on True, ADB awake, or the power
|
|
# plug is on — every one of those is a source that reported
|
|
# something). An open app or an awake device reads IDLE, not ON.
|
|
# A bare foreground app is a report in its own right: the protocol's
|
|
# current-app push or the ADB mResumedActivity parse answered.
|
|
if self._is_power_on() is not None:
|
|
reported = True
|
|
if manager is not None and manager.awake is not None:
|
|
reported = True
|
|
# EITHER transport's foreground answer counts: the protocol's
|
|
# current_app push and the ADB mResumedActivity parse are both
|
|
# "something reported", even when neither is playing anything.
|
|
if manager is not None and manager.foreground_app is not None:
|
|
reported = True
|
|
|
|
# 7. Nothing anywhere reported a power or playback fact. ON is the "we
|
|
# cannot know" state, and the only situation it is allowed to
|
|
# describe.
|
|
return MediaPlayerState.IDLE if reported else MediaPlayerState.ON
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
if self._jf_active():
|
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
|
else:
|
|
# A media session's playback controls include the BUFFERING case:
|
|
# pausing/resuming a buffering stream must be offered exactly like
|
|
# PLAYING (t55).
|
|
session = (
|
|
self._manager.best_session(self.app_id) if self._manager else None
|
|
)
|
|
if session is not None and self._session_state(session) is not None:
|
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
|
else:
|
|
features = _BASE_FEATURES
|
|
|
|
# t44/t47: a command that exists ONLY as a script still has to be
|
|
# reachable, and the feature flag is what makes HA offer it. volume_set has
|
|
# no native path at all; select_source appears when EITHER a script is
|
|
# configured OR there is a source list to offer (t47 gave it a native path).
|
|
if self._script_for("volume_set"):
|
|
features |= MediaPlayerEntityFeature.VOLUME_SET
|
|
if self._script_for("select_source") or self.source_list:
|
|
features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
|
return 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):
|
|
"""The raw foreground package from the manager's store (t41).
|
|
|
|
Both native transports stamp it: the protocol current-app push and the
|
|
ADB mResumedActivity parse. None until one of them has reported.
|
|
"""
|
|
return self._manager.current_app if self._manager is not None else None
|
|
|
|
@property
|
|
def _app_name_map(self) -> dict:
|
|
"""The user's apps name/icon map (P4-1 options editor)."""
|
|
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
|
|
|
|
@property
|
|
def _app_directory(self) -> dict:
|
|
"""Launchable apps: the user's map, else the curated default (P4-1).
|
|
|
|
ONE source for the source list and the reverse lookup (t61 removed the
|
|
browse tree that used to be the third consumer), so the dropdown can
|
|
never offer a name the launch path cannot resolve.
|
|
|
|
The map is hand-editable, so it is NORMALIZED here — once, so that every
|
|
consumer sees the same thing (t51):
|
|
|
|
* a name is stripped, so display and match key are one string: an entry
|
|
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
|
|
"TiviMate", instead of being shown padded and missing every lookup;
|
|
* an entry whose name — or whose package — is blank/whitespace-only is
|
|
dropped, so no surface can offer a child with no package behind it or a
|
|
name no launch can reach (the t48 invariant, extended to the table);
|
|
* two packages normalizing to the same name are BOTH KEPT — each stays
|
|
reachable by its own package id, which ``select_source`` accepts (t61:
|
|
the browse tree that used to publish those ids is gone) — while
|
|
the FIRST in map order wins the name: that is the single entry
|
|
source_list offers and the package select_source resolves. Map order is
|
|
the user's order, so the winner does not move between rebuilds. "The same
|
|
name" is judged case-insensitively (t53), because the reverse lookup is:
|
|
names differing only by case would otherwise give the dropdown a second
|
|
row that launches the FIRST entry's app.
|
|
|
|
A well-formed map (what the editor and the curated default produce) passes
|
|
through byte-identical: stripping only touches the edges, so a name like
|
|
"My App" keeps its inner space.
|
|
"""
|
|
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
|
normalized: dict[str, str] = {}
|
|
for package, name in directory.items():
|
|
key = str(package).strip()
|
|
label = str(name).strip()
|
|
if not key or not label:
|
|
continue
|
|
normalized[key] = label
|
|
return normalized
|
|
|
|
@property
|
|
def source_list(self) -> list[str]:
|
|
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
|
|
|
|
Directory order (the user's map order, or the curated list), so rebuilding
|
|
the page does not shuffle it. Blanks are already gone and names already
|
|
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
|
|
one accessor so the dropdown and the reverse lookup cannot disagree.
|
|
|
|
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
|
|
first spelling the user typed. Exact duplicates were already collapsed, but
|
|
``Dup``/``dup`` were not: the dropdown then showed two rows while the
|
|
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
|
|
of them to the first entry, so the second row launched the wrong app. One
|
|
row per name means a row can only launch the app it shows. The shadowed
|
|
package is not lost: ``select_source`` accepts its own package id.
|
|
"""
|
|
seen: set[str] = set()
|
|
offered: list[str] = []
|
|
for name in self._app_directory.values():
|
|
folded = name.casefold()
|
|
if folded in seen:
|
|
continue
|
|
seen.add(folded)
|
|
offered.append(name)
|
|
return offered
|
|
|
|
@property
|
|
def source(self) -> str | None:
|
|
"""The foreground app's friendly name — the same chain as app_name (t47).
|
|
|
|
``resolve_app_name``: the user's map, then the cached device-manifest label,
|
|
then the raw package. Same answer as the Current App sensor, so a
|
|
universal-media-player replacement cannot see two different truths.
|
|
"""
|
|
return self.app_name
|
|
|
|
@property
|
|
def app_name(self):
|
|
"""P5-1 resolution order — explicit override first:
|
|
|
|
1. CONF_APP_NAME_MAP (the user's explicit override)
|
|
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
|
demand — a miss enqueues the fetch and falls through)
|
|
3. raw package (protocol push / ADB foreground parse)
|
|
"""
|
|
# t40: one implementation, shared with the Current App sensor
|
|
# (``resolve_app_name``) — the two can no longer disagree about what
|
|
# is on screen.
|
|
package = self._manager.foreground_app if self._manager is not None else None
|
|
return resolve_app_name(
|
|
package,
|
|
self._app_name_map,
|
|
self._manager.app_label if self._manager is not None else 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):
|
|
"""Jellyfin session picture, else the P5-2 apps-map icon override."""
|
|
if self._jf_active():
|
|
jf = self._jf_attr("entity_picture")
|
|
if jf:
|
|
return jf
|
|
package = self._manager.foreground_app if self._manager else None
|
|
if package:
|
|
icon_map = self._entry.options.get(CONF_APP_ICON_MAP, {})
|
|
return icon_map.get(package)
|
|
return 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"),
|
|
"via": "jellyfin",
|
|
}
|
|
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 _now_playing(self) -> dict | None:
|
|
"""The first tier with something to say (t65).
|
|
|
|
Order is the ladder's own confidence order (t55): Jellyfin, then the live
|
|
ADB media session, then the foreground app. The tier that answers is the
|
|
one named in ``now_playing_via``.
|
|
"""
|
|
for reading in (
|
|
self._get_now_playing(),
|
|
self._get_adb_now_playing(),
|
|
self._get_app_now_playing(),
|
|
):
|
|
if reading:
|
|
return reading
|
|
return None
|
|
|
|
def _session_playback_label(self, info) -> str:
|
|
"""``playing``/``buffering``/``paused``/``idle`` for a live session (t55).
|
|
|
|
BUFFERING is its own label rather than a flavour of playing: the state
|
|
entity says so, and the now-playing dict must not contradict it. The
|
|
session flags themselves stay independent; this only names the state.
|
|
"""
|
|
if info.buffering:
|
|
return "buffering"
|
|
if info.playing:
|
|
return "playing"
|
|
if info.paused:
|
|
return "paused"
|
|
return "idle"
|
|
|
|
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 or info.buffering:
|
|
return {
|
|
"state": self._session_playback_label(info),
|
|
"title": self.app_name or self.app_id or "Playing",
|
|
"via": "media_session",
|
|
}
|
|
return None
|
|
|
|
if not info.title:
|
|
return None
|
|
np = {
|
|
"state": self._session_playback_label(info),
|
|
"title": info.title,
|
|
"via": "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.
|
|
|
|
The STATE here is ``idle`` or the last-known session's playback state;
|
|
the entity-level ladder reports IDLE for this tier (t55), so the two
|
|
agree — a bare foreground app is no longer reported as ON.
|
|
"""
|
|
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 or info.buffering):
|
|
state = self._session_playback_label(info)
|
|
# 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 and not self._adb_store_info:
|
|
# F3: privacy mode first — identity stays the app, so neither the
|
|
# channel number nor the media title can leak as the identity.
|
|
title = self.app_name or self.app_id or foreground
|
|
if title is None and self._last_channel and (
|
|
# F1: the channel number belongs to the channel app only — never
|
|
# leak it to the launcher or another foreground app.
|
|
foreground == TIVIMATE_PACKAGE or foreground in manager.audio_playback
|
|
):
|
|
title = self._last_channel
|
|
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:
|
|
# Bare foreground app: no now-playing identity, but the device is
|
|
# ON — the entity ladder reports IDLE for this (t55).
|
|
return None
|
|
return {"state": state, "title": title, "via": "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(),
|
|
)
|
|
|
|
# G12 (t30): attributes that change with every poll must not bloat the
|
|
# database (entity doc) — dag internals are declared unrecorded.
|
|
# t65: the flattened now-playing surface replaces the single nested
|
|
# ``media_dag_now_playing`` key, so EVERY name it can publish is declared here
|
|
# (``now_playing_attribute_names()``) — otherwise the recorder starts storing a
|
|
# fresh row per poll for each of them.
|
|
_unrecorded_attributes = frozenset(
|
|
{
|
|
*now_playing_attribute_names(),
|
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
|
ATTR_MEDIA_DAG_ROLE,
|
|
}
|
|
)
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
"""Extra state attributes — a PROPERTY, as HA requires (t40 blocker).
|
|
|
|
``Entity.__async_calculate_state`` merges them with
|
|
``attr |= self.extra_state_attributes``; declaring this as a plain
|
|
method made every state write raise
|
|
``TypeError: 'method' object is not iterable``, so the entity could
|
|
never render on a live install.
|
|
"""
|
|
attrs = {
|
|
"app_id": self.app_id,
|
|
"app_name": self.app_name,
|
|
ATTR_MEDIA_DAG_ROLE: "source",
|
|
}
|
|
# t65: the reading is FLATTENED into separate top-level attributes.
|
|
# OFF is excluded deliberately: a Jellyfin session that has gone stale
|
|
# must not publish a title beside a device that is off ("no stale title
|
|
# from a previous app lingering"), and the state already says nothing is
|
|
# playing. Every other state publishes whatever the tier answered, and
|
|
# nothing it did not (no None rows — see ``flatten_now_playing``).
|
|
if self.state != MediaPlayerState.OFF:
|
|
state = self.state.value
|
|
reading = self._now_playing()
|
|
if reading:
|
|
# The published state is the ENTITY's own state, so a dashboard
|
|
# never reads two answers: the tier can briefly serve a
|
|
# last-known playback state (t55's TTL bridge) or sit inside an
|
|
# intent window, and the state ladder is the authority in both.
|
|
reading = {**reading, "state": state}
|
|
attrs.update(flatten_now_playing(reading))
|
|
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 — our own transports only (t41: the external entity is gone) ──
|
|
|
|
async def _power_key(self, *, send_when_on: bool) -> None:
|
|
"""Send the protocol POWER key when the known state disagrees (t41).
|
|
|
|
Both native transports are ours now, so this no longer delegates to an
|
|
androidtv_remote entity: the protocol POWER key is the path we own. An
|
|
unknown state sends nothing (no superfluous toggle — the same rule the
|
|
remote entity applies), and without the protocol transport there is
|
|
nothing to transport the key over.
|
|
"""
|
|
manager = self._manager
|
|
if manager is None or manager.is_on is not send_when_on:
|
|
return
|
|
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
|
|
|
|
def _script_for(self, command: str) -> str | None:
|
|
"""The script configured to own ``command``, if any (t44)."""
|
|
return command_scripts(self._entry.options).get(command)
|
|
|
|
async def _script_served(self, command: str, variables: dict | None = None) -> bool:
|
|
"""True when a configured script handled ``command`` instead of the transports."""
|
|
return await async_run_command_script(
|
|
self.hass, self._entry.options, command, variables
|
|
)
|
|
|
|
async def _ensure_receiver_on(self) -> None:
|
|
"""Turn a SWITCH receiver on before playback — only when it is off (t61).
|
|
|
|
The user's request, verbatim: "it would be nice if a receiver could just
|
|
be a switch, I don't need my volume scripts then because the receiver
|
|
portion could just make sure the receiver is on if it's a switch". So a
|
|
``switch.*`` receiver is ENSURED ON by the playback entry points
|
|
(:meth:`async_turn_on`, :meth:`async_play_media`,
|
|
:meth:`async_select_source`) and nothing else happens to it: no volume,
|
|
no toggle, no other switch semantics.
|
|
|
|
Deliberately narrow, in four ways:
|
|
|
|
* only a ``switch.*`` receiver is touched — a ``media_player.*``
|
|
receiver keeps its behaviour exactly;
|
|
* only when the switch reports ``off``. An ``on`` switch issues no
|
|
service call at all, so this is idempotent — a state write, a poll or
|
|
a second entry point in the same action never re-calls it. A state we
|
|
cannot read (missing entity, ``unavailable``, ``unknown``) is left
|
|
alone rather than guessed at, with a debug line saying so;
|
|
* never called from a state read, a poll, a listener or attribute
|
|
rendering, so a receiver that is off while the Shield is idle stays
|
|
off — this is a playback action, not a synchronisation;
|
|
* a service failure (the entity vanished between the read and the call)
|
|
is logged, not raised: a receiver that cannot be switched on must not
|
|
stop the Shield from playing.
|
|
"""
|
|
entity_id = self._receiver_entity
|
|
if not entity_id or not entity_id.startswith(f"{RECEIVER_SWITCH_DOMAIN}."):
|
|
return
|
|
state = self._read(entity_id)
|
|
if state is None:
|
|
_LOGGER.debug(
|
|
"receiver %s is not in the state machine — not switching it",
|
|
entity_id,
|
|
)
|
|
return
|
|
if state.state != STATE_OFF:
|
|
if state.state != STATE_ON:
|
|
_LOGGER.debug(
|
|
"receiver %s reports %r — not switching it", entity_id, state.state
|
|
)
|
|
return
|
|
try:
|
|
await self.hass.services.async_call(
|
|
RECEIVER_SWITCH_DOMAIN,
|
|
"turn_on",
|
|
{"entity_id": entity_id},
|
|
blocking=True,
|
|
)
|
|
except HomeAssistantError as err:
|
|
_LOGGER.debug("receiver %s could not be turned on: %s", entity_id, err)
|
|
|
|
async def async_turn_on(self):
|
|
"""Wake the Shield (only when it reports OFF) — or run the turn_on script.
|
|
|
|
A ``switch.*`` receiver is ensured on first (t61): the receiver is where
|
|
the Shield's sound comes out, so a playback entry point that wakes the
|
|
Shield wakes it too — and only when it is actually off.
|
|
"""
|
|
await self._ensure_receiver_on()
|
|
if await self._script_served("turn_on"):
|
|
return
|
|
await self._power_key(send_when_on=False)
|
|
|
|
async def async_turn_off(self):
|
|
"""Sleep the Shield (only when it reports ON) — or run the turn_off script."""
|
|
if await self._script_served("turn_off"):
|
|
return
|
|
await self._power_key(send_when_on=True)
|
|
|
|
async def async_volume_up(self):
|
|
if await self._script_served("volume_up"):
|
|
return
|
|
await self._remote_key_cmd("volume_up", "volume_up")
|
|
|
|
async def async_volume_down(self):
|
|
if await self._script_served("volume_down"):
|
|
return
|
|
await self._remote_key_cmd("volume_down", "volume_down")
|
|
|
|
async def async_mute_volume(self, mute):
|
|
"""Mute — via the configured script, else the protocol/ADB key (C-VOL)."""
|
|
if await self._script_served("volume_mute", {SCRIPT_VAR_MUTED: mute}):
|
|
return
|
|
await self._remote_key_cmd("volume_mute", "mute")
|
|
|
|
async def async_set_volume_level(self, volume):
|
|
"""Set absolute volume — reachable only with a script (t44).
|
|
|
|
There is no native path to replace: the protocol carries UP/DOWN/MUTE keys
|
|
only (C-VOL) and ADB has no absolute volume either. Configured, this is the
|
|
hook for a device whose volume is owned elsewhere (a speaker switch);
|
|
unconfigured it stays the no-op it has always been.
|
|
"""
|
|
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
|
|
|
|
def _source_package(self, source) -> str | None:
|
|
"""The package behind a source name, or a package id passed through (t47).
|
|
|
|
Both sides are normalized the same way: the query is stripped here and the
|
|
names were stripped when the directory was built (t51), so a padded entry
|
|
matches its trimmed spelling. Names are compared case-insensitively — which
|
|
is why ``source_list`` collapses names that differ only by case (t53), so
|
|
that the row it offers resolves back to the same app it displays. When two
|
|
packages share a name the first in map order wins, the same one
|
|
``source_list`` offers (see ``_app_directory``); the shadowed package stays
|
|
reachable by passing its id.
|
|
"""
|
|
directory = self._app_directory
|
|
wanted = str(source or "").strip()
|
|
if not wanted:
|
|
return None
|
|
if wanted in directory:
|
|
# an automation may hand us the package directly; that is the id
|
|
# this table is keyed by (t61: the browse tree that used to publish
|
|
# it is gone, the id itself is unchanged)
|
|
return wanted
|
|
folded = wanted.casefold()
|
|
for package, name in directory.items():
|
|
if str(name).casefold() == folded:
|
|
return package
|
|
return None
|
|
|
|
async def async_select_source(self, source):
|
|
"""Select an app by its friendly name (t47) — script first, else launch it.
|
|
|
|
A configured ``select_source`` script keeps precedence (t44: selecting a
|
|
source can be more than launching an app — an AVR input, a speaker switch).
|
|
Without one the name is resolved back to its package through the SAME
|
|
directory the source list came from and launched over the shared
|
|
``play_media("app", …)`` path, so a universal-media-player replacement gets
|
|
the native behaviour. An unknown name is an error, never a silent no-op.
|
|
|
|
A ``switch.*`` receiver is ensured on first (t61), before the script: a
|
|
source selection is the start of playback, and it dispatches internally
|
|
so the switch is looked at exactly once per user action.
|
|
"""
|
|
await self._ensure_receiver_on()
|
|
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
|
|
return
|
|
package = self._source_package(source)
|
|
if package is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unknown_source",
|
|
translation_placeholders={"source": str(source)},
|
|
)
|
|
await self._dispatch_play_media(MediaType.APP, package)
|
|
|
|
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
|
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
|
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
|
|
|
|
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: protocol key push → ADB keyevent (t41: our own transports only —
|
|
the transitional external-entity tier is gone). With neither transport
|
|
up the command is a no-op, never a call into another integration.
|
|
"""
|
|
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
|
|
|
|
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)
|
|
|
|
# ── BrowseMedia app directory (P4-2c) ──
|
|
|
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
|
"""The media browser is EMPTY on purpose — and says so unambiguously.
|
|
|
|
The user's report, verbatim: "Media Browser shows Apps. To me it should
|
|
probably be integrated with the home assistant media display, keep
|
|
select sources for apps". They are right about the surface: HA's media
|
|
browser is for BROWSABLE media, while those rows are SOURCES. They stay
|
|
where a source belongs — ``source_list``/``source``, the source
|
|
dropdown, ``select_source`` and ``play_media("app", …)`` — and that
|
|
offer is complete.
|
|
|
|
So this returns a valid, standard ``media_player.BrowseMedia`` root with
|
|
NO children rather than a second, inconsistent copy of the app list:
|
|
``BrowseMedia`` (never ``BrowseMediaSource``, which media_player has not
|
|
exported since before t37 D1), the device as the root title, a directory
|
|
class, and ``BROWSE_ROOT_ID``/``BROWSE_ROOT_TYPE``. There is no node to
|
|
select, so no dead entry can be offered and a click can never reach a row
|
|
that cannot launch. An integration with genuinely browsable media would
|
|
publish it here; this one has none.
|
|
|
|
t63 — and the id is a real STRING, which is the fix for the next live
|
|
report, "Browse media seems to spin and never finish loading". This
|
|
method returns with no awaits, so the hang was in what it handed back:
|
|
a null ``media_content_id``. HA core never serves one (see
|
|
``BROWSE_ROOT_ID``), because the frontend keys the node it is loading by
|
|
that id. Every requested id answers this same node, so a repeat request
|
|
is idempotent rather than a loop.
|
|
"""
|
|
return BrowseMedia(
|
|
title=self._entry.title or "NVIDIA Shield",
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=BROWSE_ROOT_TYPE,
|
|
media_content_id=BROWSE_ROOT_ID,
|
|
can_play=False,
|
|
can_expand=False,
|
|
children=[],
|
|
)
|
|
|
|
# ── play_media (§7): channel digits, app/url launch ──
|
|
|
|
async def async_play_media(self, media_type: str, media_id: str) -> None:
|
|
"""HA's ``play_media`` entry point — ensure the receiver, then dispatch.
|
|
|
|
The receiver ensure (t61) lives on the PUBLIC path only, so an internal
|
|
caller (:meth:`async_select_source`) dispatches without a second look at
|
|
the same switch inside one user action.
|
|
"""
|
|
await self._ensure_receiver_on()
|
|
await self._dispatch_play_media(media_type, media_id)
|
|
|
|
async def _dispatch_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.
|
|
TEXT entry (P5-2): play_media("text", "...") — protocol IME send_text
|
|
first; the ADB `input text` fallback tier runs only when the
|
|
protocol send is unavailable or failed (the Shield may swallow IME
|
|
text while its on-screen keyboard is up, RESEARCH_IDEAS §2).
|
|
"""
|
|
if media_type == "text":
|
|
if not media_id:
|
|
_LOGGER.warning("play_media(text): empty text ignored")
|
|
return
|
|
if self._manager is not None and await self._manager.send_text_ime(media_id):
|
|
return
|
|
if self._manager is not None and await self._manager.send_adb_input_text(media_id):
|
|
return
|
|
_LOGGER.warning(
|
|
"play_media(text): no transport available (protocol down, ADB"
|
|
" unavailable) — text not sent"
|
|
)
|
|
return
|
|
|
|
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 (t41: our own
|
|
# transports only — an unserved digit is dropped, never handed
|
|
# to another integration)
|
|
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:
|
|
_LOGGER.warning(
|
|
"channel digit %s not sent — no available transport", digit
|
|
)
|
|
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
|
|
|
|
|
|
|
|
# ── P4-2a: the option-gated adb_command service ──
|
|
# Entities register their manager as a target when their entry enables the
|
|
# option; the domain service unregisters once the last participant unloads
|
|
# (core androidtv service posture: documented, opt-in, default off).
|
|
_ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
|
|
|
|
_ADB_COMMAND_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("command"): str,
|
|
vol.Required("entity_id"): str,
|
|
}
|
|
)
|
|
|
|
|
|
async def _async_adb_command_service(call) -> None:
|
|
"""Run a raw ADB shell command on the target connection (t29 G8).
|
|
|
|
The integration's async_setup registers this ALWAYS; the entry's
|
|
adb_command_enabled option gates USE (participants register from
|
|
async_added_to_hass). Failures raise ServiceValidationError with
|
|
translated keys (action-exceptions) — never warning logs.
|
|
"""
|
|
command = str(call.data.get("command") or "").strip()
|
|
if not command:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN, translation_key="invalid_command"
|
|
)
|
|
entity_id = call.data.get("entity_id")
|
|
manager = _ADB_COMMAND_TARGETS.get(entity_id)
|
|
if manager is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unsupported_target",
|
|
)
|
|
poller = manager.adb_poller # public accessor (t21 F6)
|
|
if poller is None or not manager.connected:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN, translation_key="adb_unavailable"
|
|
)
|
|
result = await poller.shell(command)
|
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|