feat(config-flow,sensor): the discovered apps reach the UI and dashboards (t57)

SURFACES
- The Configure menu gains a **Discover installed apps** row. A row rather
  than a form step, because a menu choice is dispatched with no user input:
  it awaits the manager's bounded sweep and returns to the menu with a line
  saying what was found. Discovery is therefore always explicit — never a
  per-poll cost — and a failed sweep still renders the menu.
- The Apps table opens on the REAL app list: the stored rows first (kept
  position, name and icon untouched) and then every discovered app, named
  by its device label or its package id when the label fetch did not answer.
  The user's entry for a package always wins over the device label.
- `sensor.<name>_installed_apps` (ADB-enabled entries only): state = app
  count, `installed_apps` attribute = the same table, `discovery` attribute
  = ok / pending / not_connected / adb_disabled. It polls through the
  manager's TTL cache, so a poll on a fresh table issues no shell, and the
  standard `homeassistant.update_entity` action refreshes it.

The discovered icon is deliberately NOT written into the icon column: that
column is documented as a picture URL or a `camera.*` entity feeding
`entity_picture`, and an `mdi:` name there would be a value the media
player cannot use. The icons are offered in the installed-apps attribute,
and the table's description names them.

strings.json + translations/en.json: the `installed_apps` entity name, the
Discover row label, and the menu description now carries {discovery}, so the
page says how many apps were found (or why none were).

README: an "App discovery" section documenting both commands, how system
and launcher entries are treated, the label/caching semantics, the icon
rule and its limits, the sensor surface and the fallback behaviour — and
that the source-list fallback stays the user's map, not the discovery.
This commit is contained in:
James Mackay committed 2026-10-12 07:28:55 +01:00
1 parent ee3f2845af
commit a56ec38170
5 files changed
+306 -14

No files matched your search

+87 -1
View File
@@ -12,6 +12,7 @@ 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,
@@ -28,6 +29,10 @@ async def async_setup_entry(
) -> 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))
@@ -41,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity):
_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"})
_unrecorded_attributes = frozenset(
{"app_id", "app_name", "playback_state", "installed_apps"}
)
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
@@ -148,6 +155,85 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
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. 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.
_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 on demand (explicitly requested only)."""
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)."""
manager = self._manager()
if manager is None:
return {"installed_apps": [], "discovery": "unavailable"}
apps = manager.installed_apps()
attrs = {"installed_apps": apps}
if not apps:
if not self._entry.options.get(CONF_ADB_ENABLED, True):
attrs["discovery"] = "adb_disabled"
elif not manager.connected:
attrs["discovery"] = "not_connected"
else:
attrs["discovery"] = "pending"
else:
attrs["discovery"] = "ok"
return attrs
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin."""