The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.
* the probe greps ON THE DEVICE for the three shapes Android prints
(`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
name, `null` and a resource id are not. The dump is never truncated.
* after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
is taken at its word: the sweep stops spending a shell per package
(APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
its retry — t57 UNIT 0 survives, driven by the app on screen).
* ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
by the media player, the Current App sensor, the discovery table, the source
list and the apps editor: user map > device label > curated map > package id
(the id shown ONCE, `name` empty when nothing names the app).
* the curated table covers the user's 21 apps (community-maintained, not
device truth) and the Configure row says when the device named nothing.
* the real device shape is a test fixture (trimmed, and marked as such in the
file: the raw capture is not in the repo) pinning that no label is
extracted, that the probe count is bounded, and that `Netflix` surfaces.
Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.
No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""Sensor entities for Nvidia Shield."""
|
|
|
|
import logging
|
|
|
|
from homeassistant.components.sensor import SensorEntity
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
|
from homeassistant.core import Event, HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.event import async_track_state_change_event
|
|
|
|
from .connection import ShieldConnectionManager
|
|
from .const import (
|
|
CONF_ADB_ENABLED,
|
|
CONF_APP_NAME_MAP,
|
|
CONF_JELLYFIN_ENTITY,
|
|
DOMAIN,
|
|
)
|
|
from .media_player import resolve_app_name
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the Nvidia Shield sensor entities."""
|
|
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
|
if entry.options.get(CONF_ADB_ENABLED, True):
|
|
# discovery is an ADB feature; with the transport off there is nothing
|
|
# to enumerate and the entity would only ever report "unavailable"
|
|
entities.append(NvidiaShieldAppsSensor(hass, entry))
|
|
|
|
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
|
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
|
|
|
async_add_entities(entities)
|
|
|
|
|
|
class NvidiaShieldSensorBase(SensorEntity):
|
|
"""Base class for Shield sensors."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_should_poll = False
|
|
# G12 (t30): poll-driven attributes never belong in the database
|
|
_unrecorded_attributes = frozenset(
|
|
{"app_id", "app_name", "playback_state", "installed_apps"}
|
|
)
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
self.hass = hass
|
|
self._entry = entry
|
|
self._unsub_listeners: list = []
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
|
name=self._entry.title,
|
|
manufacturer="NVIDIA",
|
|
model="Shield TV",
|
|
)
|
|
|
|
async def async_will_remove_from_hass(self) -> None:
|
|
for unsub in self._unsub_listeners:
|
|
unsub()
|
|
self._unsub_listeners.clear()
|
|
|
|
|
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|
"""Current running app on the Shield.
|
|
|
|
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"
|
|
_attr_icon = "mdi:application"
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
super().__init__(hass, entry)
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
|
|
|
def _manager(self) -> ShieldConnectionManager | None:
|
|
manager = getattr(self._entry, "runtime_data", None)
|
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
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_store_update(self) -> None:
|
|
self.async_write_ha_state()
|
|
|
|
def _package(self) -> str | None:
|
|
"""The raw package of the app that is on screen (t41, t55).
|
|
|
|
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
|
mResumedActivity (both via ``manager.foreground_app``) → the most
|
|
recent visible ADB recent task. The recents tier is a last resort for
|
|
a moment when neither live signal has reported yet; it keeps the
|
|
sensor from going None while ADB is still warming up.
|
|
"""
|
|
manager = self._manager()
|
|
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
|
|
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 device label → the built-in app
|
|
names (t66) → the package text itself. A bare or unknown package shows its
|
|
package text rather than None, like the media player's app_name, and that
|
|
text is the id ONCE — never a ``pkg=pkg`` pair.
|
|
|
|
This is why ``_package`` must be the CURRENT app and the label fetch
|
|
must be driven off it (t55): the device-manifest label is resolved on
|
|
demand for exactly the package passed here, so a live run showing a
|
|
package id means the label tier never answered — see
|
|
``ShieldConnectionManager.app_label`` for the caching rule that makes
|
|
a miss retry instead of being stamped as an eternal "no label".
|
|
"""
|
|
manager = self._manager()
|
|
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,
|
|
)
|
|
|
|
@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 NvidiaShieldAppsSensor(NvidiaShieldSensorBase):
|
|
"""The apps installed on the Shield (t57) — a dashboard-facing surface.
|
|
|
|
The state is the app COUNT and the ``installed_apps`` attribute is the
|
|
table: ``[{package, name, icon}, ...]``, exactly what the Configure page's
|
|
Apps sub-screen edits. It is the answer to "it doesn't find all the apps":
|
|
the list comes from the device (``pm list packages -3`` plus the active
|
|
launcher), labelled through the cached ``pm dump`` path.
|
|
|
|
Only ADB-enabled entries get one. Discovery is a handful of shells, so it
|
|
runs once when the entity is added and then only when the table has gone
|
|
stale: ``async_update`` goes through the manager's TTL cache, so a poll on a
|
|
fresh table issues NO shell. A sweep that could not run is NOT an answer
|
|
(t62 F1): it is recorded as ``unknown`` — or shown as ``not_connected``
|
|
while the transport itself is down — and retried on the next update instead
|
|
of being frozen as a stamped-empty table for APP_LIST_TTL, while a
|
|
sweep that ran and found nothing IS the answer ``ok`` with a count of 0. On
|
|
top of that the user can ask for a refresh with the standard
|
|
``homeassistant.update_entity`` action, and the Configure page runs the same
|
|
sweep from its discovery row. With ADB off, or unreachable, the attributes
|
|
say why and the state is 0 rather than raising.
|
|
"""
|
|
|
|
_attr_translation_key = "installed_apps"
|
|
_attr_icon = "mdi:apps"
|
|
# Polling is what makes the standard `homeassistant.update_entity` action
|
|
# work for it — the user-facing "refresh the app list" trigger. The poll is
|
|
# free while the table is fresh: the manager returns the cache and issues no
|
|
# shell until APP_LIST_TTL expires. A sweep that COULD NOT run leaves no
|
|
# fresh table (t62 F1), so a later poll retries it — that retry is the
|
|
# recovery the old stamped-empty answer blocked for up to six hours.
|
|
_attr_should_poll = True
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
super().__init__(hass, entry)
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps"
|
|
|
|
def _manager(self) -> ShieldConnectionManager | None:
|
|
manager = getattr(self._entry, "runtime_data", None)
|
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
manager = self._manager()
|
|
if manager is not None:
|
|
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
|
# one discovery per entity lifetime; a load is already a wake-up moment,
|
|
# and nothing here runs on a state poll
|
|
self.hass.async_create_task(self.async_update())
|
|
|
|
@callback
|
|
def _handle_store_update(self) -> None:
|
|
if self.hass is not None:
|
|
self.async_write_ha_state()
|
|
|
|
async def async_update(self) -> None:
|
|
"""Refresh the table when there is no fresh answer (t57, t62 F1).
|
|
|
|
HA's own poll (``_attr_should_poll``) and an explicit
|
|
``homeassistant.update_entity`` both land here; the manager's TTL makes
|
|
it a no-op while the table is fresh, and a retry while it is not —
|
|
including after a sweep that could not run.
|
|
"""
|
|
manager = self._manager()
|
|
if manager is None:
|
|
return
|
|
await manager.async_refresh_installed_apps()
|
|
|
|
@property
|
|
def native_value(self) -> int:
|
|
manager = self._manager()
|
|
return len(manager.installed_apps()) if manager is not None else 0
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
"""The table itself, plus why it may be empty (never a crash).
|
|
|
|
``discovery`` is the honest reason (t62 F1): ``ok`` means the table IS
|
|
the answer, including a genuinely empty one, while ``unknown`` means
|
|
the last sweep could not run — so an empty list says nothing about the
|
|
device and the next update retries.
|
|
"""
|
|
manager = self._manager()
|
|
if manager is None:
|
|
return {"installed_apps": [], "discovery": "unavailable"}
|
|
apps = manager.installed_apps()
|
|
attrs = {"installed_apps": apps}
|
|
if apps:
|
|
attrs["discovery"] = "ok"
|
|
elif not self._entry.options.get(CONF_ADB_ENABLED, True):
|
|
attrs["discovery"] = "adb_disabled"
|
|
elif not manager.connected:
|
|
attrs["discovery"] = "not_connected"
|
|
else:
|
|
# "ok" here is a real empty answer, "unknown" a sweep that could
|
|
# not run, "pending" one that has not completed yet.
|
|
attrs["discovery"] = manager.installed_apps_status
|
|
return attrs
|
|
|
|
|
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
|
"""What's currently playing via Jellyfin."""
|
|
|
|
_attr_translation_key = "now_playing"
|
|
_attr_icon = "mdi:play-circle"
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
super().__init__(hass, entry)
|
|
self._jellyfin_entity = entry.options[CONF_JELLYFIN_ENTITY]
|
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_now_playing"
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
self._unsub_listeners.append(
|
|
async_track_state_change_event(
|
|
self.hass, [self._jellyfin_entity], self._handle_state_change
|
|
)
|
|
)
|
|
|
|
@callback
|
|
def _handle_state_change(self, event: Event) -> None:
|
|
self.async_write_ha_state()
|
|
|
|
@property
|
|
def native_value(self) -> str | None:
|
|
state = self.hass.states.get(self._jellyfin_entity)
|
|
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
|
|
return "Idle"
|
|
|
|
attrs = state.attributes
|
|
title = attrs.get("media_title", "")
|
|
series = attrs.get("media_series_title")
|
|
|
|
if series:
|
|
season = attrs.get("media_season", "")
|
|
episode = attrs.get("media_episode", "")
|
|
return f"{series} S{season}E{episode} - {title}"
|
|
return title or "Playing"
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> dict:
|
|
state = self.hass.states.get(self._jellyfin_entity)
|
|
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
|
|
return {"playback_state": "idle"}
|
|
|
|
attrs = state.attributes
|
|
# t65: the same "no None rows" rule the media player's flattened surface
|
|
# follows — an attribute that reads `title: null` in the template editor
|
|
# is noise, so a field the Jellyfin entity did not answer is OMITTED
|
|
# rather than published. Zero stays: 0 s of position is a real reading.
|
|
result = {"playback_state": state.state}
|
|
for key, source in (
|
|
("title", "media_title"),
|
|
("media_type", "media_content_type"),
|
|
("series", "media_series_title"),
|
|
("season", "media_season"),
|
|
("episode", "media_episode"),
|
|
("duration", "media_duration"),
|
|
("position", "media_position"),
|
|
("thumbnail", "entity_picture"),
|
|
):
|
|
value = attrs.get(source)
|
|
if value is None or value == "":
|
|
continue
|
|
result[key] = value
|
|
|
|
return result
|