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:
1 parent
be3abae3e7
commit
dd23d01a88
3 files changed
+177
-34
No files matched your search
@@ -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)
|
_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):
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||||
|
|
||||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||||
@@ -609,22 +639,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
3. androidtv entity tier (when configured)
|
3. androidtv entity tier (when configured)
|
||||||
4. raw package (protocol push / ADB foreground parse)
|
4. raw package (protocol push / ADB foreground parse)
|
||||||
"""
|
"""
|
||||||
package = self._manager.foreground_app if self._manager else None
|
# t40: one implementation, shared with the Current App sensor
|
||||||
if package:
|
# (``resolve_app_name``) — the two can no longer disagree about what
|
||||||
mapped = self._app_name_map.get(package)
|
# is on screen.
|
||||||
if mapped:
|
package = self._manager.foreground_app if self._manager is not None else None
|
||||||
return mapped
|
|
||||||
label = self._manager.app_label(package)
|
|
||||||
if label:
|
|
||||||
return label
|
|
||||||
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
||||||
if atv is not None:
|
return resolve_app_name(
|
||||||
app_name = atv.attributes.get("app_name")
|
package,
|
||||||
if app_name:
|
self._app_name_map,
|
||||||
return app_name
|
self._manager.app_label if self._manager is not None else None,
|
||||||
if self._manager is not None and package:
|
atv.attributes.get("app_name") if atv is not None else None,
|
||||||
return package
|
)
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_content_type(self):
|
def media_content_type(self):
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ from homeassistant.helpers.event import async_track_state_change_event
|
|||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .media_player import resolve_app_name
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -101,14 +103,23 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
def _handle_store_update(self) -> None:
|
def _handle_store_update(self) -> None:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@property
|
def _androidtv_state(self):
|
||||||
def native_value(self) -> str | None:
|
"""The configured androidtv entity's state object, or None."""
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
if not self._androidtv_entity:
|
||||||
if state:
|
return None
|
||||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
return self.hass.states.get(self._androidtv_entity)
|
||||||
|
|
||||||
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
def _package(self) -> str | None:
|
||||||
# protocol current_app push → ADB mResumedActivity → ADB recents
|
"""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()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is not None:
|
||||||
foreground = manager.foreground_app
|
foreground = manager.foreground_app
|
||||||
@@ -120,19 +131,34 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def native_value(self) -> str | None:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
"""The resolved friendly name (t40), not the raw package id.
|
||||||
if state:
|
|
||||||
return {
|
Exactly the ``media_player.app_name`` chain via the shared
|
||||||
"app_id": state.attributes.get("app_id"),
|
``resolve_app_name``: apps map → cached manifest label → androidtv
|
||||||
"app_name": state.attributes.get("app_name"),
|
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()
|
manager = self._manager()
|
||||||
if manager is not None:
|
state = self._androidtv_state()
|
||||||
running = manager.running_apps
|
return resolve_app_name(
|
||||||
if running:
|
self._package(),
|
||||||
return {"app_id": running[0].get("package")}
|
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||||
return {}
|
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):
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
|
|||||||
@@ -1172,3 +1172,95 @@ class TestBrowseMediaDirectory:
|
|||||||
await player.async_play_media("app", selected.media_content_id)
|
await player.async_play_media("app", selected.media_content_id)
|
||||||
|
|
||||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
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"
|
||||||
Reference in new issue
Block a user