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

+153 -8
View File
@@ -130,6 +130,41 @@ def _app_map_from_text(
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:
"""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
step directly. Returning the menu here is what every sub-screen comes
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(
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):
"""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)
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 = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
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):
stored = self._options_dict
schema_dict = {
# One ROW per line, exactly the stored shape and the documented
# editor format: `package=name` with an optional `|icon`. The field
# is pre-filled with the serialized map (never the dict), and the
# empty state is simply blank — no discovery data exists yet in this
# task, so there are no rows to invent and nothing to crash on.
# editor format: `package=name` with an optional `|icon`. The rows
# are the user's own map PLUS everything discovery found on the
# device, and the field carries TEXT in every case (never the
# stored dict — that was the [object Object] bug).
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):
"""Scripts: per-command overrides and the channel digit behaviour."""
@@ -1320,23 +1446,42 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
# ── 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.
``_options_field_values`` is what serializes the apps map for the wire,
and the script selectors are suggested from the normalized
``command_scripts`` dict — the same accessor the service layer reads, so
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)
for command, script in command_scripts(self._options_dict).items():
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(
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), suggested
),
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 .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."""
+5 -1
View File
@@ -142,11 +142,12 @@
"step": {
"init": {
"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": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close"
},
@@ -308,6 +309,9 @@
},
"now_playing": {
"name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
}
}
@@ -142,11 +142,12 @@
"step": {
"init": {
"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": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close"
},
@@ -308,6 +309,9 @@
},
"now_playing": {
"name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
}
}