From 86aa2827a20a4aba88dab9942ed346dfed4df3ce Mon Sep 17 00:00:00 2001 From: James Mackay Date: Sun, 11 Oct 2026 06:09:43 +0100 Subject: [PATCH] refactor(config): drop the androidtv media_player entity path end-to-end (t41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- custom_components/nvidia_shield/__init__.py | 12 +- custom_components/nvidia_shield/button.py | 9 +- .../nvidia_shield/config_flow.py | 107 ++++---------- custom_components/nvidia_shield/helpers.py | 28 +--- .../nvidia_shield/media_player.py | 113 ++++++++------- custom_components/nvidia_shield/sensor.py | 57 +++----- tests/test_command_routing.py | 125 +++++++++------- tests/test_config_flow_ux.py | 14 +- tests/test_standalone_setup.py | 133 +++++++----------- 9 files changed, 247 insertions(+), 351 deletions(-) diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index 4bf8247..5376933 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -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 " diff --git a/custom_components/nvidia_shield/button.py b/custom_components/nvidia_shield/button.py index 08889ab..c4ae2af 100644 --- a/custom_components/nvidia_shield/button.py +++ b/custom_components/nvidia_shield/button.py @@ -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) diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 80056a5..3f43dea 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -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): diff --git a/custom_components/nvidia_shield/helpers.py b/custom_components/nvidia_shield/helpers.py index 3c52f7b..5723884 100644 --- a/custom_components/nvidia_shield/helpers.py +++ b/custom_components/nvidia_shield/helpers.py @@ -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)) diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 3ca8e7c..ef59357 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -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 ( diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index 43e0607..f91dabf 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -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 diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 03f9b16..49286eb 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -116,6 +116,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None): player._entry = entry player._manager = manager player._intent = None + player._intent_timer_unsub = None # mirrors __init__ (intent self-heal) player._last_channel = None player.hass = entity_stublib.FakeHass() player._entity_tasks = set() @@ -123,13 +124,16 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None): player.async_write_ha_state = lambda: None player._read = lambda eid: None + # t41: nothing may leave this integration, so an HA service call is now the + # failure signal rather than a supported tier. `served` stays empty unless a + # regression reintroduces the external-entity delegation. served = [] - async def _cmd(service, **kwargs): - served.append(("legacy", service)) - return None + class _RecordingServices: + async def async_call(self, domain, service, data=None, **kwargs): + served.append((domain, service)) - player._cmd = _cmd + player.hass.services = _RecordingServices() captured_intents = [] @@ -248,7 +252,8 @@ class TestChannelDigits: await player._channel_entry_sequence(["7"]) - assert served == [("legacy", "play_media")] # transitional path used + # t41: both transports down is a no-op — no other integration is called + assert served == [] class TestAppAndUrls: @@ -346,38 +351,67 @@ class TestRemoteEntity: assert protocol.key_calls == [] +class TestIntentRetryWithoutATransport: + """t41: an intent no transport served surrenders instead of delegating.""" + + def _intent(self, **overrides): + from types import SimpleNamespace + + intent = SimpleNamespace( + target_state="playing", + command="media_play", + created_at=0.0, + prior_state=None, + retry_count=0, + via_adb=False, + via_protocol=False, + adb_command=None, + protocol_key=None, + ) + for key, value in overrides.items(): + setattr(intent, key, value) + return intent + + async def test_unserved_intent_surrenders_without_a_service_call(self, modules): + player, served, _intents = make_player(modules) + player.async_write_ha_state = lambda: None + player._intent = self._intent() + + player._on_intent_timeout(0.0) # async_call_later passes the timestamp + + assert player._intent is None # surrendered to the truth + assert served == [] # and nothing left this integration + + async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules): + player, served, _intents = make_player(modules) + player.async_write_ha_state = lambda: None + protocol = player._manager._protocol + player._intent = self._intent( + via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY" + ) + + player._on_intent_timeout(0.0) # async_call_later passes the timestamp + await asyncio.sleep(0.05) + + assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")] + assert served == [] + + class TestAppFallback: - async def test_app_id_prefers_the_androidtv_entity(self, modules): + """t41: the app identity comes from OUR transports' store only.""" + + async def test_app_id_comes_from_the_manager_store(self, modules): player, _s, _i = make_player(modules) - player._androidtv_entity = "media_player.atv" - from types import SimpleNamespace as FakeState - - state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"}) - states = FakeState(states=FakeState( - get=lambda eid: state if eid == "media_player.atv" else None - )) - player.hass = states - player._read = lambda eid: state if eid == "media_player.atv" else None - - assert player.app_id == "com.plex.hdx" - assert player.app_name == "Plex" - - async def test_app_falls_back_to_protocol_current_app(self, modules): - player, _s, _i = make_player(modules) - player._androidtv_entity = "" # standalone setup (P3-1) - player._manager._on_current_app("com.netflix.ninja") + player._manager._on_current_app("com.plex.hdx") await asyncio.sleep(0.05) # drain the call_soon_threadsafe push - assert player.app_id == "com.netflix.ninja" - assert player.app_name == "com.netflix.ninja" # package is truth; map later + assert player.app_id == "com.plex.hdx" + assert player.app_name == "com.plex.hdx" # package is truth; map later - async def test_app_unavailable_entity_falls_back(self, modules): + async def test_app_follows_the_latest_protocol_push(self, modules): player, _s, _i = make_player(modules) - player._androidtv_entity = "media_player.atv" - from types import SimpleNamespace as FakeState - - player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone - player._read = lambda eid: None + player._manager._on_current_app("com.netflix.ninja") + await asyncio.sleep(0.05) player._manager._on_current_app("com.google.android.youtube.tv") await asyncio.sleep(0.05) @@ -393,7 +427,6 @@ def _ladder_player(modules, foreground=None, audio=None): manager.store.set("foreground", foreground) if audio: manager.store.set("audio_playback", audio) - player._androidtv_entity = "" player._read = lambda eid: None return player, manager @@ -411,7 +444,6 @@ class TestForegroundLadderTier: manager.store.set("foreground", foreground) if audio: manager.store.set("audio_playback", audio) - player._androidtv_entity = "" player._read = lambda eid: None return player, manager @@ -1187,12 +1219,16 @@ def make_manager_with(*, poller=None, protocol=None): ) -def make_app_sensor(modules, manager, entry, *, atv_state=None): - """A Current App sensor over the given manager/entry (t40).""" +def make_app_sensor(modules, manager, entry): + """A Current App sensor over the given manager/entry (t40). + + t41: the sensor reads the manager's store and nothing else, so the harness + hands it a hass whose state machine is empty — a value here can only come + from our own transports. + """ from types import SimpleNamespace - states = {"media_player.atv": atv_state} if atv_state is not None else {} - hass = SimpleNamespace(states=SimpleNamespace(get=states.get)) + hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None)) return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry) @@ -1232,20 +1268,6 @@ class TestCurrentAppSensorFriendlyName: assert sensor.native_value == "Netflix" assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja" - def test_androidtv_entity_tier_serves_when_no_map_or_label(self, modules): - from types import SimpleNamespace - - player, _served, _intents = make_player(modules) - player._entry.data["androidtv_entity"] = "media_player.atv" - atv_state = SimpleNamespace( - attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"} - ) - sensor = make_app_sensor(modules, player._manager, player._entry, atv_state=atv_state) - - player._manager._on_current_app("ar.tvplayer.tv") - - assert sensor.native_value == "ATV Tivi" - def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules): player, _served, _intents = make_player(modules) manager = player._manager @@ -1271,7 +1293,6 @@ class TestCurrentAppSensorFriendlyName: """One chain, two entities — they cannot disagree about the app name.""" player, _served, _intents = make_player(modules) player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} - player._androidtv_entity = "" # standalone, like the unit harness sensor = self._sensor(modules, player) player._manager._on_current_app("ar.tvplayer.tv") diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index 9d23525..d218827 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -578,7 +578,7 @@ class TestDiscoveryPrefill: await flow.async_step_zeroconf(_discovery()) result = await flow.async_step_user( - _user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: False}) + _user_input(**{CONF_ADB_ENABLED: False}) ) assert result["errors"] == {"base": "no_state_source"} @@ -630,7 +630,6 @@ class TestNoDeadEndPairing: form = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: DISCOVERED_HOST, } @@ -662,7 +661,6 @@ class TestNoDeadEndPairing: form = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True, CONF_ADB_HOST: DISCOVERED_HOST, } @@ -695,7 +693,6 @@ class TestNoDeadEndPairing: form = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True, CONF_ADB_HOST: DISCOVERED_HOST, @@ -718,7 +715,6 @@ class TestNoDeadEndPairing: await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True, CONF_ADB_HOST: DISCOVERED_HOST, @@ -742,7 +738,6 @@ class TestNoDeadEndPairing: form = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: DISCOVERED_HOST, } @@ -767,7 +762,6 @@ class TestNoDeadEndPairing: form = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True, CONF_ADB_HOST: DISCOVERED_HOST, } @@ -787,7 +781,7 @@ class TestNoDeadEndPairing: test_connect = _patch_adb(monkeypatch, module) flow, _hass = _new_flow(module, tmp_path) await flow.async_step_user( - _user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True}) + _user_input(**{CONF_ADB_ENABLED: True}) ) result = await flow.async_step_adb_pair( @@ -806,7 +800,6 @@ class TestNoDeadEndPairing: await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: DISCOVERED_HOST, } @@ -835,7 +828,7 @@ class TestNoDeadEndPairing: _patch_adb(monkeypatch, module, connected=False) flow, _hass = _new_flow(module, tmp_path) await flow.async_step_user( - _user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True}) + _user_input(**{CONF_ADB_ENABLED: True}) ) result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555}) @@ -850,7 +843,6 @@ class TestNoDeadEndPairing: await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_ADB_ENABLED: True, CONF_ADB_HOST: DISCOVERED_HOST, } diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 158b928..45d6793 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -289,12 +289,21 @@ def _entry_module(): return sys.modules["nsh.entry"] +def _record(sink): + """An async_set_unique_id stand-in that records what the flow tried.""" + + async def _set(unique_id): + sink.append(unique_id) + + return _set + + def _user_input(**overrides) -> dict: base = { "name": "mdarcy", - CONF_ANDROIDTV_ENTITY: "media_player.shield", CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: "", + CONF_ADB_ENABLED: False, } base.update(overrides) return base @@ -306,21 +315,25 @@ class TestStandaloneValidation: flow = module.NvidiaShieldConfigFlow() flow.hass = FakeHass(str(tmp_path)) - result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""})) + result = await flow.async_step_user(_user_input()) assert result["type"] == "form" assert result["step_id"] == "user" assert result["errors"] == {"base": "no_state_source"} - async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path): + async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path): + """t41: we own the stack — no other integration is a state source.""" module = _form_module() flow = module.NvidiaShieldConfigFlow() flow.hass = FakeHass(str(tmp_path)) - result = await flow.async_step_user(_user_input()) + result = await flow.async_step_user(None) - assert result["type"] == "create_entry" - assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"} + fields = vol_mirror.declared_fields(result["data_schema"]) + assert CONF_ANDROIDTV_ENTITY not in fields + # the two native transports are what is left to choose from + assert CONF_PROTOCOL_ENABLED in fields + assert CONF_ADB_ENABLED in fields async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch): module = _form_module() @@ -345,20 +358,19 @@ class TestStandaloneValidation: ) result = await flow.async_step_user( - _user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True}) + _user_input(**{CONF_PROTOCOL_ENABLED: True}) ) assert result["type"] == "form" assert result["step_id"] == "protocol_pair" # routed into pairing, no error - async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch): - """No androidtv entity → the key is absent from entry data (not "").""" + async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch): + """t41: every value lives in options; `data` carries no leftovers.""" module = _form_module() flow = module.NvidiaShieldConfigFlow() flow.hass = FakeHass(str(tmp_path)) flow._user_data = { "name": "mdarcy", - CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local", } @@ -367,7 +379,7 @@ class TestStandaloneValidation: result = flow._create_entry() assert result["type"] == "create_entry" - assert CONF_ANDROIDTV_ENTITY not in result["data"] + assert result["data"] == {} class TestAdbOnlyStandalone: @@ -380,7 +392,6 @@ class TestAdbOnlyStandalone: result = await flow.async_step_user( _user_input( **{ - CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: False, "adb_enabled": True, } @@ -398,7 +409,7 @@ class TestAdbOnlyStandalone: flow.hass = FakeHass(str(tmp_path)) result = await flow.async_step_user( - _user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False}) + _user_input(**{"adb_enabled": False}) ) assert result["errors"] == {"base": "no_state_source"} @@ -567,69 +578,33 @@ class TestChannelEntryOptions: assert result["errors"] == {"base": "invalid_digit_delay"} -class TestUniqueIdSourceG2: - """t29 G2: no unique_id equals the androidtv entity_id anymore.""" +class TestUniqueIdSource: + """t41: no unique_id is derived from another integration, at any stage. - def test_entity_unique_id_fallback_is_gone(self): - from pathlib import Path + t29 G2 replaced the renameable entity_id with a device-MAC derive; t41 + removes the entity path entirely, so the ONLY identity source left is the + protocol MAC learned during pairing (``async_set_unique_id(mac)``). + """ - base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield" - flow_src = (base / "config_flow.py").read_text() - # the historic renameable fallback is gone from the user step + def test_no_entity_derived_unique_id_remains(self): + flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8") + + assert "_unique_id_from_atv_device" not in flow_src assert "async_set_unique_id(androidtv_entity)" not in flow_src - # the documented replacement: a device-MAC derive, else no unique_id - assert "_unique_id_from_atv_device" in flow_src - - def test_entity_rename_never_moves_the_unique_id(self): - """The MAC-derived id is rename-proof; a MAC-less entity has none.""" - + # no registry lookups for another integration's devices either + assert "device_registry" not in flow_src + async def test_the_user_step_sets_no_unique_id(self, tmp_path): module = _form_module() - # a registered atv entity whose device carries a MAC - entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv") - device = SimpleNamespace( - connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")] - ) - er_stub = module.er - er_stub.async_get = lambda hass: SimpleNamespace( - entities={"media_player.shield": entity} - ) - class _DevRegistry: - def __init__(self, by_id): - self._by_id = by_id + flow = module.NvidiaShieldConfigFlow() + flow.hass = FakeHass(str(tmp_path)) + flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} + recorded = [] + flow.async_set_unique_id = _record(recorded) - def async_get(self, device_id): - return self._by_id.get(device_id) + await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True})) - module.dr.async_get = lambda hass: _DevRegistry({"dev1": device}) - - # the resolved unique_id is the MAC — not the entity_id - uid = module._unique_id_from_atv_device( - FakeHass("/tmp"), "media_player.shield" - ) - - assert uid == "aa:bb:cc:dd:ee:ff" - - # renaming the entity does not affect the resolved value - er_stub.async_get = lambda hass: SimpleNamespace( - entities={"media_player.renamed": entity} - ) - uid_renamed = module._unique_id_from_atv_device( - FakeHass("/tmp"), "media_player.renamed" - ) - - assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames - - def test_macless_entity_yields_no_unique_id(self): - - - module = _form_module() - er_stub = module.er - er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered - assert ( - module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x") - is None - ) + assert recorded == [] class TestListenerComboGone: @@ -1220,7 +1195,6 @@ class TestAppNameMapResolution: modules = entity_stublib.load_entity_modules() player, _s, _i = _cmd_player(modules) player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} - player._androidtv_entity = "" # standalone manager = player._manager manager._on_current_app("ar.tvplayer.tv") # protocol push @@ -1235,7 +1209,6 @@ class TestAppNameMapResolution: modules = entity_stublib.load_entity_modules() player, _s, _i = _cmd_player(modules) player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} - player._androidtv_entity = "" manager = player._manager manager._on_current_app("com.google.android.youtube.tv") @@ -1243,23 +1216,17 @@ class TestAppNameMapResolution: assert player.app_name == "com.google.android.youtube.tv" - async def test_entity_tier_serves_when_no_higher_tier_matches(self): - """P5-1: the entity tier is below map/manifest — it serves when the - package has no explicit override and no manifest label yet.""" - from types import SimpleNamespace as SimpleNS - + async def test_cached_manifest_label_is_the_second_tier(self): + """P5-1: below the explicit map sits the device manifest label.""" import entity_stublib modules = entity_stublib.load_entity_modules() player, _s, _i = _cmd_player(modules) player._entry.options["app_name_map"] = {} # no override - player._androidtv_entity = "media_player.atv" - state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"}) - player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state)) - player._read = lambda eid: state if eid == "media_player.atv" else None + manager = player._manager + manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock()) - player._manager._on_current_app("ar.tvplayer.tv") + manager._on_current_app("ar.tvplayer.tv") await asyncio.sleep(0.05) - # no cached label yet → falls through to the entity tier - assert player.app_name == "ATV Tivi" + assert player.app_name == "TiviMate"