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:
1 parent
60d884b76a
commit
86aa2827a2
9 files changed
+247
-351
No files matched your search
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user