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:
1 parent
ee3f2845af
commit
a56ec38170
5 files changed
+306
-14
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user