fix(sensor): F2 — the Current App sensor shows the resolved app name (t40)

Reported from a live install: the sensor showed ``ar.tvplayer.tv`` while the
media player showed "TiviMate" — the same foreground app, two identities.

The sensor's ``native_value`` returned ``manager.foreground_app`` (the raw
package push) or ``running_apps[0]["package"]`` as-is, while the media player
resolved names through apps map → cached manifest label → androidtv entity tier.
Both now call ONE function, ``resolve_app_name`` (media_player.py), so the two
entities cannot disagree again; the sensor keeps the raw package as its
``app_id`` attribute and ``app_name`` carries the shown name.

Per the contract:
* apps map wins, then the cached manifest label, then the androidtv entity's
  ``app_name``, then the package text itself;
* a bare or unknown package falls back to the package text, NOT None (it used to
  be possible for the value to vanish while the app was plainly running);
* pinned by tests: map tier, label tier, entity tier, the ADB-recents source, the
  unknown-package fallback, and sensor == media player for the same inputs.

Verified: 302 passed / 2 skipped, ruff clean.
This commit is contained in:
James Mackay committed 2026-10-11 06:02:30 +01:00
1 parent be3abae3e7
commit dd23d01a88
3 files changed
+177 -34

No files matched your search

+40 -15
View File
@@ -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):
+45 -19
View File
@@ -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):