The user's remote displayed but offered no app picker, and the protocol transport was never actually switched on. Both halves are fixed here. REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly what core's androidtv_remote declares — and publishes activity_list, which is what makes the frontend render an activity picker at all. The list is the app directory (the user's apps map, else the curated table as a floor) in map order, then the apps ADB discovery found and something can name, in the one name chain (user map > device label > curated > package id). Nothing nameless is listed as a bare package id, and current_activity is None until something is known: no activity is invented. remote.turn_on honours activity= by waking the device when it reports itself off and then launching through the existing launch path; a listed name resolves case-insensitively, a package id or a detail link passes through, and anything else raises unknown_activity naming the value instead of silently doing nothing. An empty/absent activity keeps the previous behaviour exactly, async_turn_off is untouched, and a configured command script still owns the sequence. WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting only starts _async_reconnect, whose whole body is `while self._remote_message_protocol:` — false on the first check with nothing connected, so the task returned immediately: no connect attempt, no retry, and the auth callback was never reachable. HA core connects FIRST and only then keeps it alive. Our _start_protocol called keep_reconnecting after generating a certificate and never called async_connect anywhere, so every send_key_command/send_launch_app_command raised ConnectionClosed internally, the helpers swallowed it into `return False` plus a debug line, and no push (is_available/is_on/current_app/volume_info) ever arrived. The fix is a background connect loop: async_connect under a bounded timeout, network failures retried with the ADB watchdog's jitter and backoff, InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth), and keep_reconnecting only after a session is up. Setup never blocks on a transport. A send that cannot run is no longer silent: it is counted, its error recorded verbatim, and logged at WARNING with the transport state, and the remote publishes protocol_state / protocol_connected / protocol_ever_authenticated / protocol_last_error / protocol_failures / protocol_certificate plus sticky per-push *_seen flags — so "sent" and "never switched on" can never look alike again. CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options flow for an entry that never paired now routes into the real async_step_protocol_pair, and "continue without pairing" persists the transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED), written by the setup pairing step, the reauth finisher and the reconfigure finisher, so no entry can sit enabled-but-unpaired. The certificate from the first pairing is reused by design, so a paired entry is asked for no new PIN — which is also why the marker, not certificate material, is the source of truth. ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes that were never live before, so the reading now names its tier: manager.foreground_source drives the media player's app_source and the remote's activity_source, and the new tests prove a full protocol push set leaves the media state, the now-playing reading and every ADB store field untouched, while an ADB-only entry still serves media player, sensors, apps, scripts and receiver with only the protocol surface degraded. Copy: unknown_activity, activity_launch_failed and protocol_unavailable in BOTH strings.json and translations/en.json, pinned by a test; the README documents the picker, activity resolution, how to read the transport-state attributes, the script bypass (a script-routed press is never evidence of pairing), the connect-then-keep-alive lifecycle and cert reuse versus the other integration's own pairing. Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py (unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and the setup flow's marker on success and on skip), a real-HA entity contract test in tests/test_ha_api_drift.py (exact supported_features equality with core, activity_list/current_activity reaching the state machine, live _attr_ updates) and new suites in tests/test_protocol_transport.py (connect-first, keep_reconnecting alone is a no-op, retry, auth rejection, honest send failures, ADB-primary precedence) and tests/test_command_routing.py (the activity contract, turn_on(activity=), script ownership, the transport attribute states). No device and no browser exist here: device behaviour is inferred from the library and core source, and every UI-facing claim is proven at the entity/registry/metadata level under real Home Assistant. Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py + tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks, 57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and 52->55.
1931 lines
82 KiB
Python
1931 lines
82 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 (
|
|
ATTR_DEVICE_ID,
|
|
ATTR_ENTITY_ID,
|
|
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 import config_validation as cv
|
|
from homeassistant.helpers import entity_registry as er
|
|
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 (
|
|
ADB_COMMAND_RESPONSE_KEY,
|
|
ADB_COMMAND_TARGET_DOMAIN,
|
|
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,
|
|
SERVICE_ADB_COMMAND,
|
|
TIVIMATE_PACKAGE,
|
|
command_scripts,
|
|
)
|
|
from .ha_adb_common import (
|
|
PlaybackIntent,
|
|
resolve_package_name,
|
|
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,
|
|
curated=None,
|
|
) -> str | None:
|
|
"""Resolve a package id to a friendly name (t40; curated tier t66).
|
|
|
|
The single chain behind ``media_player.app_name`` and the Current App
|
|
sensor, delegating to ``ha_adb_common.app_label.resolve_package_name`` so the
|
|
discovery table and the options editor cannot drift from it:
|
|
|
|
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
|
2. the cached device label (``manager.app_label`` — a cache miss enqueues the
|
|
on-demand fetch and falls through);
|
|
3. the curated table (``DEFAULT_APP_DIRECTORY``) — the tier that names apps on
|
|
a Shield whose ``pm dump`` exposes no label at all;
|
|
4. the raw package text — a bare or unknown package falls back to itself,
|
|
never to None, and it is shown ONCE (no ``pkg=pkg`` anywhere).
|
|
"""
|
|
return resolve_package_name(
|
|
package,
|
|
user_map=app_name_map,
|
|
# the callable keeps the device tier LAZY: the user's map answers first
|
|
device_label=app_label,
|
|
curated=DEFAULT_APP_DIRECTORY if curated is None else curated,
|
|
)
|
|
|
|
|
|
def normalize_app_directory(directory) -> dict[str, str]:
|
|
"""The t51 rule for an apps table: strip the edges, drop blanks, keep order.
|
|
|
|
ONE implementation for every consumer (the media player's source list, the
|
|
remote's activity list, the reverse lookup), so display and match key stay one
|
|
string: an entry like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
|
|
"TiviMate". An entry whose name — or whose package — is blank is dropped, so no
|
|
surface offers a child with no package behind it or a name no launch can reach.
|
|
Two packages normalizing to the same name are BOTH KEPT (each stays reachable by
|
|
its own id) and the FIRST in map order wins the name (t53).
|
|
"""
|
|
normalized: dict[str, str] = {}
|
|
for package, name in (directory or {}).items():
|
|
key = str(package).strip()
|
|
label = str(name).strip()
|
|
if not key or not label:
|
|
continue
|
|
normalized[key] = label
|
|
return normalized
|
|
|
|
|
|
def app_directory(options) -> dict[str, str]:
|
|
"""Launchable apps for an entry: the user's map, else the curated default (t61).
|
|
|
|
ONE source for the source list and the reverse lookup, so the dropdown can
|
|
never offer a name the launch path cannot resolve (``_app_directory``, and now
|
|
the remote's activity list, both read this).
|
|
"""
|
|
return normalize_app_directory(
|
|
(options or {}).get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
|
)
|
|
|
|
|
|
def distinct_app_names(names) -> list[str]:
|
|
"""Each name ONCE, compared case-insensitively, keeping the first spelling (t53).
|
|
|
|
``Dup``/``dup`` would otherwise show two rows while the case-insensitive
|
|
reverse lookup resolves both to the first entry — one row can only launch the
|
|
app it shows. The shadowed package is not lost: it stays reachable by id.
|
|
"""
|
|
seen: set[str] = set()
|
|
offered: list[str] = []
|
|
for name in names:
|
|
text = str(name)
|
|
folded = text.casefold()
|
|
if not text or folded in seen:
|
|
continue
|
|
seen.add(folded)
|
|
offered.append(text)
|
|
return offered
|
|
|
|
|
|
def activity_names(options, discovered=()) -> list[str]:
|
|
"""The remote entity's ACTIVITY list: friendly names, stable order (t15).
|
|
|
|
Core ``androidtv_remote`` builds its list from a user-maintained app map
|
|
because ``AndroidTVRemote`` exposes NO app-list API (measured on the installed
|
|
library, not assumed). Ours is the same shape of source plus what ADB discovery
|
|
actually found on THIS device:
|
|
|
|
1. the entry's app directory (the user's map, else the curated table) in map
|
|
order — the same names the media player's source list offers;
|
|
2. apps discovery found that no directory row covers, named by the one chain
|
|
(user map → device label → curated → id).
|
|
|
|
A discovered app NOTHING can name is skipped rather than offered as its own
|
|
package id (the ``pkg=pkg`` noise this contract forbids); it stays launchable by
|
|
id through ``remote.turn_on(activity=...)``. Nothing is invented here: an empty
|
|
directory with no discovery yields an empty list, and the protocol's pairing
|
|
state cannot add an entry.
|
|
"""
|
|
directory = app_directory(options)
|
|
names = list(directory.values())
|
|
covered = set(directory)
|
|
for row in discovered or ():
|
|
package = str(row.get("package") or "").strip()
|
|
if not package or package in covered:
|
|
continue
|
|
covered.add(package)
|
|
label = str(row.get("name") or "").strip()
|
|
if not label or label == package:
|
|
continue
|
|
names.append(label)
|
|
return distinct_app_names(names)
|
|
|
|
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; t15).
|
|
|
|
The rule lives in the module-level :func:`app_directory` (t51: strip the
|
|
edges, drop blanks, keep map order, first name wins case-insensitively, the
|
|
shadowed package still reachable by id) so the source list, the reverse
|
|
lookup AND the remote's activity list cannot disagree about it.
|
|
"""
|
|
return app_directory(self._entry.options)
|
|
|
|
@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.
|
|
"""
|
|
return distinct_app_names(self._app_directory.values())
|
|
|
|
@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,
|
|
# t15: which tier produced the app identity ("protocol" push / ADB parse),
|
|
# so the newly-reachable push cannot silently outrank the ADB reading the
|
|
# user's setup is built on — the same disclosure now_playing_via makes.
|
|
"app_source": (
|
|
self._manager.foreground_source if self._manager is not None else None
|
|
),
|
|
|
|
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,
|
|
# t14: the UI's target picker sends the SELECTED Shield as `entity_id`
|
|
# (a string, or a LIST — HA's picker sends a list for a picked target),
|
|
# and an automation that targets by device sends `device_id`. Both arrive
|
|
# here as text-or-list and are resolved to exactly ONE participant in the
|
|
# handler: a command must never be sent to a Shield the user did not pick,
|
|
# and "which one?" is never guessed.
|
|
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
|
vol.Optional(ATTR_DEVICE_ID): cv.ensure_list_csv,
|
|
}
|
|
)
|
|
|
|
|
|
def _requested_adb_command_targets(call) -> list[str]:
|
|
"""The entity ids the caller selected, in order, deduplicated (t14).
|
|
|
|
TWO shapes reach here, both of them HA's own: the UI's target picker merges the
|
|
selection into the call as ``entity_id``, and an automation written against a
|
|
device target sends ``device_id``. A value is a string or a list (the picker
|
|
sends a list).
|
|
|
|
A device_id expands ONLY through this integration's own registry entries, so a
|
|
device id can never pull in an unrelated integration's entities — and with no
|
|
registry to consult it expands to nothing (an error naming the target, never a
|
|
guess).
|
|
"""
|
|
requested: list[str] = []
|
|
for value in cv.ensure_list(call.data.get(ATTR_ENTITY_ID)):
|
|
text = str(value).strip()
|
|
if text:
|
|
requested.append(text)
|
|
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID))
|
|
if device_ids:
|
|
hass = getattr(call, "hass", None)
|
|
registry = er.async_get(hass) if hass is not None else None
|
|
for device_id in device_ids:
|
|
text = str(device_id).strip()
|
|
if not text or registry is None:
|
|
continue
|
|
# MEASURED on HA 2026.10.1: ``async_entries_for_device`` is a
|
|
# MODULE-LEVEL helper that takes the registry — the EntityRegistry class
|
|
# does not carry the method — and only the real-HA file drives it.
|
|
for entry in er.async_entries_for_device(registry, text):
|
|
# A Shield DEVICE carries sensors, a remote and an image entity too;
|
|
# only the media player is something this action can act on.
|
|
if entry.domain == ADB_COMMAND_TARGET_DOMAIN:
|
|
requested.append(entry.entity_id)
|
|
return list(dict.fromkeys(requested))
|
|
|
|
|
|
def _single_adb_command_target(call) -> str:
|
|
"""Exactly ONE participating Shield, or a ServiceValidationError (t14).
|
|
|
|
The three failures are told apart because the fix differs: nothing selected
|
|
(``no_target`` — pick the Shield), something selected that does not participate
|
|
(``unsupported_target`` — the entry's option is off, or it is another
|
|
integration's entity), and more than one selected (``ambiguous_target`` — a raw
|
|
shell command is never broadcast, and never aimed at the wrong Shield).
|
|
"""
|
|
requested = _requested_adb_command_targets(call)
|
|
if not requested:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_target",
|
|
translation_placeholders={"service": f"{DOMAIN}.{SERVICE_ADB_COMMAND}"},
|
|
)
|
|
unknown = [
|
|
entity_id for entity_id in requested if entity_id not in _ADB_COMMAND_TARGETS
|
|
]
|
|
if unknown:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unsupported_target",
|
|
translation_placeholders={"entity_id": ", ".join(unknown)},
|
|
)
|
|
if len(requested) > 1:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="ambiguous_target",
|
|
translation_placeholders={"entity_id": ", ".join(requested)},
|
|
)
|
|
return requested[0]
|
|
|
|
|
|
async def _async_adb_command_service(call):
|
|
"""Run a raw ADB shell command on the SELECTED Shield (t29 G8, t14).
|
|
|
|
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.
|
|
|
|
t14 — THE TARGET. The service is registered domain-wide with a runtime
|
|
description whose ``target`` is the Shield's ``media_player`` entity, so the UI
|
|
offers a picker; HA merges whatever was picked into ``call.data`` as
|
|
``entity_id`` and/or ``device_id`` (a string, or the list a picked target
|
|
produces). Resolution is deliberately strict — exactly one participant, or a
|
|
ServiceValidationError naming what to pass — because the alternative (picking a
|
|
Shield for the user) is a raw shell command sent to the wrong device.
|
|
|
|
THE RESPONSE (t14). The output comes back under ``adb_response``, the same key
|
|
the deprecated ``androidtv.adb_command`` returned, so a template written
|
|
against that integration keeps working. Callers that do not ask for a response
|
|
are unaffected: HA drops the value, and the registration is OPTIONAL (never
|
|
"only"). An unreachable Shield is an error (``adb_unavailable``), not an empty
|
|
response.
|
|
"""
|
|
command = str(call.data.get("command") or "").strip()
|
|
if not command:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN, translation_key="invalid_command"
|
|
)
|
|
entity_id = _single_adb_command_target(call)
|
|
manager = _ADB_COMMAND_TARGETS[entity_id]
|
|
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",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
result = await poller.shell(command)
|
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
|
return {ADB_COMMAND_RESPONSE_KEY: result}
|