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

+56 -3
View File
@@ -68,8 +68,10 @@ remote protocol is the fast push/command path:
4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active. 4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
5. **ADB media session** — `playing` / `paused` / `buffering` for apps that 5. **ADB media session** — `playing` / `paused` / `buffering` for apps that
publish a session (foreground app preferred). `buffering` is the media publish a session (foreground app preferred). `buffering` is the media
session's own PlaybackState 6 (`state=PlaybackState {state=6, …}`), and a session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the
buffering session advertises the playback controls exactly like `playing`. stock-Android buffering value, **inferred** from that line shape: no captured
fixture here contains `state=6` and no real device was available. A buffering
session advertises the playback controls exactly like `playing`.
6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the 6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the
power plug on) and nothing is playing, or ADB reports it asleep (standby). power plug on) and nothing is playing, or ADB reports it asleep (standby).
A powered Shield with an app open and nothing playing reads `idle`. A powered Shield with an app open and nothing playing reads `idle`.
@@ -416,12 +418,63 @@ A script that fails surfaces as a translated error on the service call that trig
it (the command is not retried over protocol/ADB), because a silent no-op is the one it (the command is not retried over protocol/ADB), because a silent no-op is the one
outcome that leaves you guessing. outcome that leaves you guessing.
### App discovery (what is actually installed)
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu
and the table is rebuilt from two ADB queries:
| Command | What it returns | How it is treated |
| --- | --- | --- |
| `pm list packages -3` | every **third-party** (user-installed) package, one `package:<id>` per line | all of them become rows |
| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended |
System apps are **not** enumerated — a Shield ships a few hundred of them and they are
not launchable apps — with the one exception that matters: the active launcher, which
is a system app yet is exactly what the user sees. Nothing else is filtered, so an app
is missing only if Android itself does not report it.
Names come from the same cached path the Current App sensor uses: a `pm dump` per
package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps
in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in
seconds instead of the minutes a strictly serialised sweep would take. A label fetch
that **fails is not cached**, so that app shows its package id in this table and the
next sweep tries again — this is the same rule as the entity path, and it is why a
transient ADB hiccup no longer sticks.
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
there is no device icon to read. Each app gets a curated `package → mdi:` icon where we
know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin —
and `mdi:television` otherwise. The icon never enters your **icon column**: that column
is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an
`mdi:` name there would put a value the media player cannot use into your own map.
Discovery never runs on a state poll: it happens when you press the row, and the result
is cached for 6 h. With ADB off, or unreachable, the table simply shows the rows you
wrote yourself and the menu says why.
**For dashboards** the same table is a sensor: `sensor.<name>_installed_apps`, whose
state is the app **count** and whose `installed_apps` attribute is the list of
`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `not_connected`
or `adb_disabled`. It only exists for ADB-enabled entries, and it is cheap to refresh —
`homeassistant.update_entity` on it re-runs the sweep only when the cache has expired,
while the Configure row forces one.
```yaml
# every installed app, with the name this integration would show
{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }}
| map(attribute='name') | list }}
```
### Sources (the app list in a dropdown) ### Sources (the app list in a dropdown)
The media player's source list is your **Apps** map — the same map the Browse tree and The media player's source list is your **Apps** map — the same map the Browse tree and
app-name resolution use. With no map configured it falls back to a small built-in list app-name resolution use. With no map configured it falls back to a small built-in list
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has (TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
something to show: something to show. Discovery fills the **Configure** table, not this fallback: the
dropdown stays exactly the map you saved, so what a card offers is always what you
chose. An app that discovery found and you want in the dropdown is one row to add.
```yaml ```yaml
# a media-player card, or anything that reads source_list # a media-player card, or anything that reads source_list
+153 -8
View File
@@ -130,6 +130,41 @@ def _app_map_from_text(
return app_map return app_map
def _apps_table_text(
stored_map: dict, stored_icons: dict, discovered: list[dict]
) -> str:
"""The Editable apps table: stored rows first, then discovered apps (t57).
Every app the Shield reports gets a row, so the page opens on the REAL app
list instead of the four curated entries ("it doesn't find all the apps").
The stored rows keep their position, name and icon — the user's edit is
never overwritten by a discovery — and a discovered app the map does not
mention is appended with the device label (or its package id when the label
fetch did not answer) and its heuristic icon.
The icon column is emitted only for a stored icon, because the discovered
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
values are a picture URL or a ``camera.*`` entity that feeds
``entity_picture``. Writing an ``mdi:`` string there would put a value the
media player cannot use into the user's own map — the icon is offered in the
table's description instead, and in the installed-apps sensor's attributes.
"""
lines: list[str] = []
seen: set[str] = set()
for package, name in (stored_map or {}).items():
seen.add(package)
icon = (stored_icons or {}).get(package)
lines.append(f"{package}={name}|{icon}" if icon else f"{package}={name}")
for app in discovered or []:
package = app.get("package")
if not package or package in seen:
continue
seen.add(package)
name = app.get("name") or package
lines.append(f"{package}={name}")
return "\n".join(lines)
def _options_field_values(options: dict | None) -> dict: def _options_field_values(options: dict | None) -> dict:
"""Entry options as FRONTEND-SERIALIZABLE field values (t56). """Entry options as FRONTEND-SERIALIZABLE field values (t56).
@@ -1101,12 +1136,69 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
manager reads ``next_step_id`` off the menu result and calls the chosen manager reads ``next_step_id`` off the menu result and calls the chosen
step directly. Returning the menu here is what every sub-screen comes step directly. Returning the menu here is what every sub-screen comes
back to. back to.
``discover`` is the explicit refresh (t57): it runs the ADB sweep and
reports what it found, so the app table is never built by accident and
nothing sweeps on a state poll.
""" """
return self.async_show_menu( return self.async_show_menu(
step_id="init", step_id="init",
menu_options=["connection", "receiver", "apps", "scripts", "finish"], menu_options=[
"connection",
"receiver",
"apps",
"discover",
"scripts",
"finish",
],
description_placeholders=self._menu_placeholders(),
) )
def _menu_placeholders(self) -> dict:
"""What the menu says about the current discovery state (t57)."""
apps = self._discovered_apps()
if apps:
return {"discovery": f"{len(apps)} apps found on the Shield"}
manager = getattr(self.config_entry, "runtime_data", None) if (
self.config_entry is not None
) else None
if manager is not None and not getattr(manager, "connected", False):
return {"discovery": "no ADB connection — nothing discovered yet"}
return {"discovery": "not discovered yet — use Discover installed apps"}
async def async_step_discover(self, user_input=None):
"""Discover the apps installed on the Shield over ADB (t57).
A menu ROW, not a form: Home Assistant dispatches ``next_step_id`` to
this method with no user input, it awaits the manager's bounded sweep,
and the menu comes back with a line saying what was found. Running it
from a row keeps the sweep explicit (never a per-poll cost) and gives it
a place to report a failure without a new field.
"""
await self._run_discovery()
return await self.async_step_init()
async def _run_discovery(self) -> None:
"""Sweep the device, swallowing every failure into the menu's note.
Discovery is a convenience: when ADB is off or down, or the sweep finds
nothing, the menu must still render and the table must still work on the
user's own rows. So nothing here raises — a failure simply leaves the
discovered list empty, which is what the placeholders report.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
sweep = getattr(manager, "async_refresh_installed_apps", None)
if sweep is None:
return
try:
await sweep(force=True)
except Exception as err: # noqa: BLE001 - a failed sweep is not an error
_LOGGER.debug("app discovery from the options page failed: %s", err)
async def async_step_menu_again(self): async def async_step_menu_again(self):
"""Return to the options menu after a sub-screen saved (t56). """Return to the options menu after a sub-screen saved (t56).
@@ -1285,22 +1377,56 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
return self._show_sub_form("receiver", schema_dict, errors) return self._show_sub_form("receiver", schema_dict, errors)
async def async_step_apps(self, user_input=None): async def async_step_apps(self, user_input=None):
"""Apps: the editable apps table — one row per app, blank when empty.""" """Apps: the editable apps table — one row per discovered + stored app."""
errors: dict = {} errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors): if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again() return await self.async_step_menu_again()
return self._show_apps_form(errors) return self._show_apps_form(errors)
def _discovered_apps(self) -> list[dict]:
"""The apps the device reported, from the last background sweep (t57).
Sources, in order: the entry's ``runtime_data`` (where the integration
puts its connection manager) and then the domain store, both of which
the options flow can reach through ``hass``. Never triggers a sweep:
this is a pure read of the manager's TTL cache, so rendering a form
cannot issue shells. When nothing has been discovered (ADB off or down,
or the first sweep still running) the list is empty and the table shows
the user's own rows — the empty state, not a crash.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
if manager is None or not hasattr(manager, "installed_apps"):
return []
return manager.installed_apps()
def _show_apps_form(self, errors=None): def _show_apps_form(self, errors=None):
stored = self._options_dict
schema_dict = { schema_dict = {
# One ROW per line, exactly the stored shape and the documented # One ROW per line, exactly the stored shape and the documented
# editor format: `package=name` with an optional `|icon`. The field # editor format: `package=name` with an optional `|icon`. The rows
# is pre-filled with the serialized map (never the dict), and the # are the user's own map PLUS everything discovery found on the
# empty state is simply blank — no discovery data exists yet in this # device, and the field carries TEXT in every case (never the
# task, so there are no rows to invent and nothing to crash on. # stored dict — that was the [object Object] bug).
vol.Optional(CONF_APP_NAME_MAP): str, vol.Optional(CONF_APP_NAME_MAP): str,
} }
return self._show_sub_form("apps", schema_dict, errors) discovered = self._discovered_apps()
suggested = _apps_table_text(
stored.get(CONF_APP_NAME_MAP) or {},
stored.get(CONF_APP_ICON_MAP) or {},
discovered,
)
icons = sorted({app.get("icon") for app in discovered if app.get("icon")})
return self._show_sub_form(
"apps",
schema_dict,
errors,
override={CONF_APP_NAME_MAP: suggested},
placeholders={"icons": ", ".join(icons) or "none known"},
)
async def async_step_scripts(self, user_input=None): async def async_step_scripts(self, user_input=None):
"""Scripts: per-command overrides and the channel digit behaviour.""" """Scripts: per-command overrides and the channel digit behaviour."""
@@ -1320,23 +1446,42 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
# ── rendering ── # ── rendering ──
def _show_sub_form(self, step_id: str, schema_dict: dict, errors: dict | None): def _show_sub_form(
self,
step_id: str,
schema_dict: dict,
errors: dict | None,
override: dict | None = None,
placeholders: dict | None = None,
):
"""Render a sub-screen with only ITS suggested values — never a dict. """Render a sub-screen with only ITS suggested values — never a dict.
``_options_field_values`` is what serializes the apps map for the wire, ``_options_field_values`` is what serializes the apps map for the wire,
and the script selectors are suggested from the normalized and the script selectors are suggested from the normalized
``command_scripts`` dict — the same accessor the service layer reads, so ``command_scripts`` dict — the same accessor the service layer reads, so
the page cannot offer a script the runtime would ignore. the page cannot offer a script the runtime would ignore.
``override`` replaces individual suggested values for a screen whose
suggestion is not simply the stored option (the Apps table is the
stored map MERGED with discovery, t57). ``placeholders`` feeds the
step's ``description_placeholders``, which is how a screen reports
something long — the Discover row's outcome — without inventing a field
for it.
""" """
suggested = _options_field_values(self._options_dict) suggested = _options_field_values(self._options_dict)
for command, script in command_scripts(self._options_dict).items(): for command, script in command_scripts(self._options_dict).items():
suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script
suggested.update(override or {})
kwargs: dict = {}
if placeholders:
kwargs["description_placeholders"] = dict(placeholders)
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id=step_id,
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), suggested vol.Schema(schema_dict), suggested
), ),
errors=errors or {}, errors=errors or {},
**kwargs,
) )
+87 -1
View File
@@ -12,6 +12,7 @@ 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_ADB_ENABLED,
CONF_APP_NAME_MAP, CONF_APP_NAME_MAP,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
DOMAIN, DOMAIN,
@@ -28,6 +29,10 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Nvidia Shield sensor entities.""" """Set up the Nvidia Shield sensor entities."""
entities = [NvidiaShieldCurrentAppSensor(hass, entry)] 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): if entry.options.get(CONF_JELLYFIN_ENTITY):
entities.append(NvidiaShieldNowPlayingSensor(hass, entry)) entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
@@ -41,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
# G12 (t30): poll-driven attributes never belong in the database # 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: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -148,6 +155,85 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
return attrs 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): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin.""" """What's currently playing via Jellyfin."""
+5 -1
View File
@@ -142,11 +142,12 @@
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield settings", "title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"menu": { "menu": {
"connection": "Main setup", "connection": "Main setup",
"receiver": "Receiver", "receiver": "Receiver",
"apps": "Apps", "apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts", "scripts": "Scripts",
"finish": "Save and close" "finish": "Save and close"
}, },
@@ -308,6 +309,9 @@
}, },
"now_playing": { "now_playing": {
"name": "Now playing" "name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
} }
} }
} }
@@ -142,11 +142,12 @@
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield settings", "title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"menu": { "menu": {
"connection": "Main setup", "connection": "Main setup",
"receiver": "Receiver", "receiver": "Receiver",
"apps": "Apps", "apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts", "scripts": "Scripts",
"finish": "Save and close" "finish": "Save and close"
}, },
@@ -308,6 +309,9 @@
}, },
"now_playing": { "now_playing": {
"name": "Now playing" "name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
} }
} }
} }