refactor(config): drop the androidtv media_player entity path end-to-end (t41)

The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.

Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
  offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
  and, with it, every registry lookup for another integration's devices — the
  user step now sets NO unique_id at all; the protocol MAC learned during
  pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
  now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
  resolve_app_name chain, the Current App sensor, the sensor's state-change
  listener and its `_androidtv_state` helper) — the manager's store is the only
  source;
* the "transitional external entity" command tier in every path: `_cmd` and the
  pan-media_player service call are gone, and so is the fallback in _media_cmd,
  _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
  (an intent no transport served now surrenders to the truth instead of
  delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
  intent" check no longer watches another integration's entity; the power
  LADDER — protocol is_on → ADB wake → power switch — is unchanged).

turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.

helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.

Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.

Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
This commit is contained in:
James Mackay committed 2026-10-11 06:09:43 +01:00
1 parent 60d884b76a
commit 86aa2827a2
9 files changed
+247 -351

No files matched your search

+5 -7
View File
@@ -24,7 +24,6 @@ from .const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
@@ -32,7 +31,7 @@ from .const import (
DOMAIN,
SERVICE_ADB_COMMAND,
)
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
from .helpers import protocol_cert_paths, resolve_adb_config
_LOGGER = logging.getLogger(__name__)
@@ -178,15 +177,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
"""Manager protocol_settings when the transport is enabled & resolvable.
Falls back to the androidtv-remote host discovery (the ADB host fallback)
until the user pairs, after which the explicit protocol host persists.
The address is the entry's own (t41): the protocol field, else the ADB
field — both name the same Shield. Nothing is derived from another
integration.
"""
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
return None
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
host = entry.options.get(CONF_PROTOCOL_HOST) or entry.options.get(CONF_ADB_HOST)
if not host:
_LOGGER.info(
"Protocol enabled for entry %s but no host resolvable — continuing "
+5 -4
View File
@@ -13,11 +13,10 @@ from .const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
DOMAIN,
)
from .helpers import discover_adb_host
_LOGGER = logging.getLogger(__name__)
@@ -55,8 +54,10 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
async def async_press(self) -> None:
"""Attempt ADB connection. The Shield will show a pairing prompt."""
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
# t41: the address is ours — never derived from another integration's
# entity. The ADB field leads, then the protocol field (same Shield).
host = self._entry.options.get(CONF_ADB_HOST) or self._entry.options.get(
CONF_PROTOCOL_HOST
)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
+28 -79
View File
@@ -6,7 +6,6 @@ import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import selector
@@ -19,7 +18,6 @@ from .const import (
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
@@ -37,7 +35,7 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
)
from .helpers import discover_adb_host, protocol_cert_paths
from .helpers import protocol_cert_paths
_LOGGER = logging.getLogger(__name__)
@@ -53,34 +51,6 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
return list(input_map.keys()) if isinstance(input_map, dict) else []
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
"""A stable unique_id from the atv entity's device MAC (t29 G2).
The device registry holds CONNECTION_NETWORK_MAC for Android TV
entities configured with a host — far stronger than the renameable
entity_id. None (→ no unique_id at all, not an entity-derived
placeholder) when the entity is unregistered or carries no MAC.
"""
if not androidtv_entity:
return None
registry = er.async_get(hass)
entity = registry.entities.get(androidtv_entity)
device_id = getattr(entity, "device_id", None) if entity else None
device = (
dr.async_get(hass).async_get(device_id) if device_id else None
)
if device is None:
return None
if isinstance(dr, dict): # stub shape guard
return None
for connection in device.connections:
# connections are {connection_type, value} pairs; only a REAL mac
# value is usable (an empty placeholder is skipped)
if connection[0] == "mac" and connection[1]:
return connection[1]
return None
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
"""Auto-detect the Jellyfin media_player entity for a device."""
registry = er.async_get(hass)
@@ -169,7 +139,7 @@ def _host_field_key(field: str, host: str):
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — the androidtv entity is optional (standalone).
"""User-step schema — every field belongs to THIS integration (t41).
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
@@ -182,7 +152,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
port = DEFAULT_ADB_PORT
schema_dict = {
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
}
@@ -207,9 +176,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
def _build_selectors():
"""Build reusable selector instances."""
return {
"androidtv": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
),
"media_player": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player")
),
@@ -243,51 +209,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
"""Step 1: Basic setup.
Standalone setup (Phase 3): any one of the three state sources is
enough — the Android TV Remote entity, the Nvidia Remote Protocol
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
``no_state_source`` error fires only when none of them is present.
We own the whole stack (t41), so the two state sources are ours: the
Nvidia Remote Protocol (pairing) and ADB media-session mode
(CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
neither is enabled.
"""
errors = {}
if user_input is not None:
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
has_source = (
bool(androidtv_entity)
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
or bool(user_input.get(CONF_ADB_ENABLED))
has_source = bool(user_input.get(CONF_PROTOCOL_ENABLED)) or bool(
user_input.get(CONF_ADB_ENABLED)
)
if not has_source:
errors["base"] = "no_state_source"
return self._show_user_form(errors)
# t29 G2: the androidtv entity_id is a renameable reference —
# never a unique_id. A sqlite/registry MAC is preferred (the
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
# resolvable MAC the entry gets NO unique_id (the same
# uniqueness-less semantics _async_handle_discovery_without_
# unique_id applies to MAC-less discovery) — renames can never
# break uniqueness.
fallback_unique_id = _unique_id_from_atv_device(
self.hass, androidtv_entity
)
if fallback_unique_id:
await self.async_set_unique_id(fallback_unique_id)
self._abort_if_unique_id_configured()
# t41: nothing here derives a unique_id from another integration.
# The entry's identity is the protocol MAC learned during pairing
# (`async_set_unique_id(mac)` in the pairing step) or the config
# entry itself — never a renameable entity id (t29 G2's
# atv-device derivation went with the entity path).
# Auto-detect Jellyfin if not manually specified
# Auto-detect Jellyfin from the names setup already knows: the
# entry name the user typed, or the device name from discovery.
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
if not jellyfin:
device_name = user_input.get("name", DEFAULT_NAME)
atv_state = (
self.hass.states.get(androidtv_entity)
if androidtv_entity
else None
)
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
for name in [device_name, atv_name]:
for name in (user_input.get("name", DEFAULT_NAME), self._discovered_name):
if not name:
continue
found = _find_jellyfin_entity(self.hass, name)
@@ -298,7 +245,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = {
"name": user_input.get("name", DEFAULT_NAME),
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
CONF_JELLYFIN_ENTITY: jellyfin or "",
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
@@ -433,14 +379,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
def _create_entry(self):
"""Create the config entry from stored user data."""
"""Create the config entry from stored user data.
Everything the integration needs lives in the OPTIONS (t41: one host
field among them); ``data`` stays empty because no value has to be
immutable.
"""
d = self._user_data
entry_data = {}
if d.get(CONF_ANDROIDTV_ENTITY):
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
return self.async_create_entry(
title=d["name"],
data=entry_data,
data={},
options={
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
@@ -722,7 +670,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# the entry already knows (and _create_entry is never reached here).
self._user_data = {
"name": entry.title,
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
@@ -774,9 +721,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reauth_successful")
def _resolve_adb_host(self) -> str | None:
"""Resolve ADB host from explicit config or the Android TV entity."""
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
"""The address to use for ADB: the ADB field, else the protocol field."""
return (
self._user_data.get(CONF_ADB_HOST)
or self._user_data.get(CONF_PROTOCOL_HOST)
or None
)
def _show_adb_pair_form(self, errors=None):
+5 -23
View File
@@ -4,12 +4,11 @@ import os
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
DOMAIN,
)
@@ -42,31 +41,14 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
def discover_adb_host(
hass: HomeAssistant, androidtv_entity_id: str | None
) -> str | None:
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
if not androidtv_entity_id:
return None
registry = er.async_get(hass)
entity_entry = registry.async_get(androidtv_entity_id)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
"""Resolve host/port/key_path for the entry's ADB connection, or None.
Priority: explicit ADB host/port options > host from the AndroidTV
entity's own config entry.
Priority: the entry's own address fields (t41: no other integration is
consulted) — the ADB field first, then the protocol field, since both
describe the same Shield.
"""
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
host = entry.options.get(CONF_ADB_HOST) or entry.options.get(CONF_PROTOCOL_HOST)
if not host:
return None
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
+61 -52
View File
@@ -53,7 +53,6 @@ from .const import (
CHANNEL_DIGIT_DELAY,
CONF_ADB_COMMAND_ENABLED,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
@@ -121,8 +120,12 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
legacy pattern no longer matches, so this never runs twice for an
entry. No behavior change: the entry's data/options are untouched.
"""
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
if not legacy or entry.unique_id != legacy:
# 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
@@ -186,18 +189,16 @@ def resolve_app_name(
package: str | None,
app_name_map,
app_label,
androidtv_app_name: str | None,
) -> str | None:
"""Resolve a package id to a friendly name (t40).
"""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 (which used to publish the raw package id):
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 androidtv entity tier (that entity's ``app_name`` attribute);
4. the raw package text — a bare or unknown package falls back to itself,
3. the raw package text — a bare or unknown package falls back to itself,
never to None.
"""
if package:
@@ -207,8 +208,6 @@ def resolve_app_name(
label = app_label(package) if app_label is not None else None
if label:
return label
if androidtv_app_name:
return androidtv_app_name
return package or None
@@ -223,7 +222,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
self._unsub_listeners: list = []
@@ -332,11 +330,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._on_manager_update
)
entities = [self._androidtv_entity]
if self._jellyfin_entity:
entities.append(self._jellyfin_entity)
if self._power_entity:
entities.append(self._power_entity)
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
@@ -436,9 +434,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._manager.send_media_command(intent.adb_command)
)
else:
self._track_task(
self._cmd(intent.command)
# 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
)
@@ -456,13 +463,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@callback
def _on_source_change(self, event: Event) -> None:
"""React to state changes in androidtv or Jellyfin."""
"""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 in (self._androidtv_entity, self._power_entity)
entity_id == self._power_entity
and new_state_obj
and not self._is_power_on()
and self._intent
@@ -640,16 +647,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property
def app_id(self):
"""Android TV entity first; standalone setups fall back to the
protocol current-app push (raw package — both stamp the store)."""
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
if atv is not None:
app_id = atv.attributes.get("app_id")
if app_id:
return app_id
if self._manager is not None:
return self._manager.current_app
return None
"""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:
@@ -663,19 +666,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
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. androidtv entity tier (when configured)
4. raw package (protocol push / ADB foreground parse)
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
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
return resolve_app_name(
package,
self._app_name_map,
self._manager.app_label if self._manager is not None else None,
atv.attributes.get("app_name") if atv is not None else None,
)
@property
@@ -922,17 +922,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
attrs["_intent"] = self._intent.to_debug_dict()
return attrs
# ── Commands — ALL go to androidtv_remote ──
# ── Commands — our own transports only (t41: the external entity is gone) ──
async def _cmd(self, service: str, **extra) -> None:
data = {"entity_id": self._androidtv_entity, **extra}
await self.hass.services.async_call("media_player", service, data)
async def _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"])
async def async_turn_on(self):
await self._cmd("turn_on")
"""Wake the Shield (only when it reports OFF)."""
await self._power_key(send_when_on=False)
async def async_turn_off(self):
await self._cmd("turn_off")
"""Sleep the Shield (only when it reports ON)."""
await self._power_key(send_when_on=True)
async def async_volume_up(self):
await self._remote_key_cmd("volume_up", "volume_up")
@@ -945,7 +957,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
await self._remote_key_cmd("volume_mute", "mute")
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
if (
protocol_key
@@ -955,16 +967,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return
if self._manager is not None and await self._manager.send_media_command(adb_key):
return
# Transitional: external androidtv_remote entity (removed in Phase 4)
await self._cmd(remote_key)
async def _media_cmd(
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
) -> None:
"""Send a media command over the fastest available command path.
Order (Phase 2): protocol key push → ADB keyevent → the external
androidtv_remote entity (transitional until Phase 4 removes it).
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)
@@ -997,9 +1008,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._intent.adb_command = adb_command
return
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
await self._cmd(ha_service)
async def async_media_play(self):
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
@@ -1134,16 +1142,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
try:
for digit in digits:
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
# 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:
await self._cmd(
"play_media",
media={"media_content_id": digit, "media_content_type": "channel"},
_LOGGER.warning(
"channel digit %s not sent — no available transport", digit
)
await asyncio.sleep(delay)
if (
+17 -40
View File
@@ -12,7 +12,6 @@ from homeassistant.helpers.event import async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import (
CONF_ANDROIDTV_ENTITY,
CONF_APP_NAME_MAP,
CONF_JELLYFIN_ENTITY,
DOMAIN,
@@ -67,9 +66,9 @@ class NvidiaShieldSensorBase(SensorEntity):
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield.
Primary source: the androidtv entity's own state. When that entity is
unavailable, falls back to the ShieldConnectionManager's StateStore
(most recent visible ADB recent-task app).
Source: the ShieldConnectionManager's StateStore — the protocol current-app
push, or the most recent visible ADB recent-task app when that has not
reported (t41: our own transports only).
"""
_attr_translation_key = "current_app"
@@ -77,7 +76,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry)
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
def _manager(self) -> ShieldConnectionManager | None:
@@ -85,49 +83,30 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
return manager if isinstance(manager, ShieldConnectionManager) else None
async def async_added_to_hass(self) -> None:
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, [self._androidtv_entity], self._handle_state_change
)
)
manager = self._manager()
if manager is not None:
# Refresh when the connection manager updates its store.
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
@callback
def _handle_state_change(self, event: Event) -> None:
self.async_write_ha_state()
@callback
def _handle_store_update(self) -> None:
self.async_write_ha_state()
def _androidtv_state(self):
"""The configured androidtv entity's state object, or None."""
if not self._androidtv_entity:
return None
return self.hass.states.get(self._androidtv_entity)
def _package(self) -> str | None:
"""The raw foreground package (t40): entity tier, then the store.
"""The raw foreground package from the manager's store (t41).
androidtv entity unavailable — StateStore priority chain (P3-3c):
protocol current_app push → ADB mResumedActivity → ADB recents.
StateStore priority chain (P3-3c): protocol current_app push → ADB
mResumedActivity → ADB recents.
"""
state = self._androidtv_state()
if state is not None:
package = state.attributes.get("app_id")
if package:
return package
manager = self._manager()
if manager is not None:
foreground = manager.foreground_app
if foreground:
return foreground
running = manager.running_apps
if running:
return running[0].get("package")
if manager is None:
return None
foreground = manager.foreground_app
if foreground:
return foreground
running = manager.running_apps
if running:
return running[0].get("package")
return None
@property
@@ -135,17 +114,15 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""The resolved friendly name (t40), not the raw package id.
Exactly the ``media_player.app_name`` chain via the shared
``resolve_app_name``: apps map → cached manifest label → androidtv
entity tier → the package text itself. A bare or unknown package shows
its package text rather than None, like the media player's app_name.
``resolve_app_name``: apps map → cached manifest label → the package
text itself. A bare or unknown package shows its package text rather
than None, like the media player's app_name.
"""
manager = self._manager()
state = self._androidtv_state()
return resolve_app_name(
self._package(),
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
manager.app_label if manager is not None else None,
state.attributes.get("app_name") if state is not None else None,
)
@property