diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 480602f..1ad49fe 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -153,6 +153,36 @@ async def _migrate_legacy_unique_id(hass, entry) -> None: _LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id) +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). + + The single chain behind ``media_player.app_name`` and the Current App + sensor (which used to publish the raw package id): + + 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, + never to None. + """ + if package: + mapped = (app_name_map or {}).get(package) + if mapped: + return mapped + label = app_label(package) if app_label is not None else None + if label: + return label + if androidtv_app_name: + return androidtv_app_name + return package or None + + class NvidiaShieldMediaPlayer(MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.TV @@ -609,22 +639,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): 3. androidtv entity tier (when configured) 4. raw package (protocol push / ADB foreground parse) """ - package = self._manager.foreground_app if self._manager else None - if package: - mapped = self._app_name_map.get(package) - if mapped: - return mapped - label = self._manager.app_label(package) - if label: - return label + # 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 - if atv is not None: - app_name = atv.attributes.get("app_name") - if app_name: - return app_name - if self._manager is not None and package: - return package - return 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 def media_content_type(self): diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index c0431d4..43e0607 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -13,9 +13,11 @@ 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, ) +from .media_player import resolve_app_name _LOGGER = logging.getLogger(__name__) @@ -101,14 +103,23 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): def _handle_store_update(self) -> None: self.async_write_ha_state() - @property - def native_value(self) -> str | None: - state = self.hass.states.get(self._androidtv_entity) - if state: - return state.attributes.get("app_name") or state.attributes.get("app_id") + 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) - # androidtv entity unavailable — StateStore priority chain (P3-3c): - # protocol current_app push → ADB mResumedActivity → ADB recents + def _package(self) -> str | None: + """The raw foreground package (t40): entity tier, then the store. + + androidtv entity unavailable — 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 @@ -120,19 +131,34 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): return None @property - def extra_state_attributes(self) -> dict: - state = self.hass.states.get(self._androidtv_entity) - if state: - return { - "app_id": state.attributes.get("app_id"), - "app_name": state.attributes.get("app_name"), - } + def native_value(self) -> str | None: + """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. + """ manager = self._manager() - if manager is not None: - running = manager.running_apps - if running: - return {"app_id": running[0].get("package")} - return {} + 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 + def extra_state_attributes(self) -> dict: + """The raw package stays visible here (t40) next to the shown name.""" + package = self._package() + attrs: dict = {} + if package: + attrs["app_id"] = package + name = self.native_value + if name: + attrs["app_name"] = name + return attrs class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 6929ad6..3aaec52 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -1172,3 +1172,95 @@ class TestBrowseMediaDirectory: await player.async_play_media("app", selected.media_content_id) assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH + + +def make_app_sensor(modules, manager, entry, *, atv_state=None): + """A Current App sensor over the given manager/entry (t40).""" + from types import SimpleNamespace + + states = {"media_player.atv": atv_state} if atv_state is not None else {} + hass = SimpleNamespace(states=SimpleNamespace(get=states.get)) + return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry) + + +class TestCurrentAppSensorFriendlyName: + """t40: the sensor shows the RESOLVED name; the raw package is an attribute. + + It used to publish ``manager.foreground_app`` — the raw package id — so a + live install showed "ar.tvplayer.tv" where the media player showed + "TiviMate". Both now resolve through the shared ``resolve_app_name`` chain. + """ + + def _sensor(self, modules, player): + # The sensor resolves the manager through entry.runtime_data — the same + # wiring a real setup gives it. + player._entry.runtime_data = player._manager + return make_app_sensor(modules, player._manager, player._entry) + + def test_mapped_package_shows_the_friendly_name(self, modules): + player, _served, _intents = make_player(modules) + player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} + sensor = self._sensor(modules, player) + + player._manager._on_current_app("ar.tvplayer.tv") + + assert sensor.native_value == "TiviMate" + assert sensor.extra_state_attributes["app_id"] == "ar.tvplayer.tv" + assert sensor.extra_state_attributes["app_name"] == "TiviMate" + + def test_cached_manifest_label_is_the_second_tier(self, modules): + player, _served, _intents = make_player(modules) + manager = player._manager + manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock()) + sensor = self._sensor(modules, player) + + manager._on_current_app("com.netflix.ninja") + + 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 + manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock()) + manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}]) + sensor = self._sensor(modules, player) + + assert sensor.native_value == "TiviMate" + + def test_unknown_package_falls_back_to_the_package_text(self, modules): + player, _served, _intents = make_player(modules) + sensor = self._sensor(modules, player) + + player._manager._on_current_app("com.example.unknown") + + assert sensor.native_value == "com.example.unknown" # not None + assert sensor.extra_state_attributes == { + "app_id": "com.example.unknown", + "app_name": "com.example.unknown", + } + + def test_sensor_and_media_player_resolve_identically(self, modules): + """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") + + assert sensor.native_value == player.app_name == "TiviMate"