fix(options): edit apps one row per step in a multiline field, and report discovery

The live Apps page showed the whole map as ONE long line with nowhere to add a
row. Cause: the field was a bare `vol.Optional(...): str`, and HA renders a
selector-less string as a SINGLE-LINE input (pre-fix wire field:
{"type": "string", "name": "app_name_map"} — no selector key). It is a
multiline TextSelector now: the real-HA test pins
selector.text.multiline on the wire and an N-line save/reopen round trip.

HA has no table selector, so the Apps sub-screen is a menu of row actions
(whole list as text / add / edit / remove) instead of one form:

- the EDIT row lists every app the user can act on: the rows they saved AND the
  apps discovery found, marked `[detected]`, each labelled "name (package id)"
  or "package id (no name yet)" — the user's Shield exposes no device labels at
  all, so the unnamed case is the common one and it must not read as its own id
  twice, and picking it is how a name gets set (saving stores the row)
- add: package + name + optional icon, refused by the SAME `_valid_app_row`
  rule the text field uses, with an error on the page
- edit: PREFILLED from the stored row (suggested values — a voluptuous
  `default=` never reaches the browser, so the form would have opened blank);
  the device's mdi: icon is never prefilled into the icon column
- remove: pick a SAVED row, then an explicit confirmation menu; cancelling
  keeps it (a detected app has no line to delete)
- edit/remove rows are offered only while something is stored
- every mutation writes ONE row through `_write_app_row`/`_drop_app_row`, so
  untouched rows and their icons stay byte-for-byte as they were

The Discover row was a silent no-op ("seems to load then do nothing"): it now
returns a one-line verdict the menu shows — N apps found / the Shield reported
none / the sweep could not reach the Shield / ADB is not set up — while still
never raising and never caching a failed sweep as an answer.

Also fixes `async_step_menu_again`, which the real FlowManager dispatches with
user_input and which raised TypeError for every menu offering that row (only
reachable through a stub before, hence unnoticed).

Docs: README and BOTH strings files (byte-identical) describe the editor, the
one-per-line format, the detected-app dropdown and the four Discover verdicts.
This commit is contained in:
James Mackay committed 2026-10-12 10:02:09 +01:00
1 parent eb62011ded
commit 46738063aa
8 files changed
+1369 -81

No files matched your search

+403 -25
View File
@@ -44,6 +44,25 @@ from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_hos
_LOGGER = logging.getLogger(__name__)
#: t64: the Apps ROW EDITOR's field names. They are UI only — a projection of one
#: ``package=name[|icon]`` row — so they never enter ``_ALL_OPTION_FIELDS`` and
#: ``_apply_submission`` drops them instead of storing them as options.
APP_FIELD_PACKAGE = "app_package"
APP_FIELD_NAME = "app_name"
APP_FIELD_ICON = "app_icon"
def _valid_app_row(package: str, name: str, icon: str = "") -> bool:
"""The ONE place a row's rules live: dotted package, no spaces, non-empty name.
The text field's parser and the row editor both call this, so a row typed into
the table and a row added through the form are accepted (or rejected)
identically — which is what lets editing one row leave every other row alone.
"""
if not package or not name or " " in package:
return False
return not (icon and " " in icon)
def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
"""Read available inputs from a receiver's media_dag_input_map attribute."""
@@ -120,11 +139,9 @@ def _app_map_from_text(
icon = parts[1].strip() if len(parts) > 1 else ""
package = package.strip()
name = name.strip()
if not package or not name or " " in package:
if not _valid_app_row(package, name, icon):
return None
if icon:
if " " in icon:
return None
icons[package] = icon
app_map[package] = name
return app_map
@@ -301,6 +318,14 @@ def _build_selectors():
"script": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="script")
),
# t64: the apps table is ONE ROW PER LINE — a bare ``str`` renders a
# SINGLE-LINE input (HA docs: "If no selector is defined, a text input
# for a single line will be shown"), so the stored multi-line map arrived
# flattened and new rows could not be typed at all. The multiline text
# selector is the same field with a real input for it.
"app_map": selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
}
@@ -1155,6 +1180,11 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
def __init__(self):
self._options: dict | None = None
# t64: the Discover row's OUTCOME, and the row the editor is working on.
# All three are UI state: they never become options.
self._discover_note: str | None = None
self._editing_package: str | None = None
self._removing_package: str | None = None
@property
def _options_dict(self) -> dict:
@@ -1193,7 +1223,16 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
)
def _menu_placeholders(self) -> dict:
"""What the menu says about the current discovery state (t57)."""
"""What the menu says about app discovery (t57/t64).
The user report (t64): "Discover installed apps seems to load then do
nothing which is confusing". So the outcome of the Discover ROW — how
many apps arrived, or why none did — outranks the state-based note once
the user has actually asked for a sweep; a failed or empty sweep is now
a sentence, not a silent no-op.
"""
if self._discover_note is not None:
return {"discovery": self._discover_note}
apps = self._discovered_apps()
if apps:
return {"discovery": f"{len(apps)} apps found on the Shield"}
@@ -1213,16 +1252,17 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
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()
self._discover_note = 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.
async def _run_discovery(self) -> str:
"""Sweep the device and report WHAT HAPPENED, in one line (t57/t64).
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.
user's own rows. So nothing here raises — but the OUTCOME is returned as
a sentence the menu shows, because "it seemed to load then did nothing"
is the one thing a user cannot distinguish from a broken integration.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
@@ -1231,19 +1271,35 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
manager = store.get(getattr(entry, "entry_id", None))
sweep = getattr(manager, "async_refresh_installed_apps", None)
if sweep is None:
return
return "ADB is not set up for this entry — nothing could be discovered."
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)
return (
"the discovery sweep could not reach the Shield — check the ADB "
"connection; your saved rows are unchanged."
)
apps = self._discovered_apps()
if not apps:
return (
"the Shield reported no installed apps — the table keeps your "
"saved rows."
)
return f"{len(apps)} apps found on the Shield — the table is updated."
async def async_step_menu_again(self):
async def async_step_menu_again(self, user_input=None):
"""Return to the options menu after a sub-screen saved (t56).
Deliberately NOT ``async_step_init``: HA dispatches a menu row to
``async_step_<row id>`` by NAME, so ``async_step_init`` is not what the
menu returns to — and calling it here would also bypass the ``step_id``
the browser expects on the next render.
``user_input`` is mandatory even though this step ignores it: the real
FlowManager calls ``async_step_<row>(user_input)`` for EVERY dispatched
menu row (t64 — this was only reachable through a stub before, and the
real manager raised TypeError on the row the Apps menu shares).
"""
return await self.async_step_init()
@@ -1420,11 +1476,328 @@ 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 discovered + stored app."""
"""The Apps editor: a MENU of row actions (t64).
Home Assistant has no table selector (checked against the full selector
list — area/device/entity/label/number/object/select/template/text/… and
no table), so a table is edited the way HA edits one: small steps that
each do ONE row operation. This menu is the entry point; ``apps_text``
keeps the whole-table paste, and ``apps_add``/``apps_edit``/
``apps_remove`` work on single rows.
A menu row is dispatched to ``async_step_<row id>``, so one row PER APP
is impossible — the ids would have to be method names. The apps are
listed in this step's description instead, and the edit/remove steps ask
WHICH app through a selector built from the same stored map. Rows that
could not do anything (edit/remove with nothing stored) are not offered.
"""
return self.async_show_menu(
step_id="apps",
menu_options=self._apps_menu_options(),
description_placeholders=self._apps_placeholders(),
)
def _stored_apps(self) -> dict:
"""The rows the map already owns, ``{package: name}`` (t64).
The text field's suggestion additionally shows discovered apps (t57) —
those are an OFFER, not a stored row — so the row editor works on this
map only, and every mutation goes through the helpers below, which keeps
the name and icon columns from drifting apart.
"""
app_map = self._options_dict.get(CONF_APP_NAME_MAP)
if not isinstance(app_map, dict):
return {}
return {str(package): str(name) for package, name in app_map.items()}
def _apps_menu_options(self) -> list[str]:
"""The menu rows, minus the ones with nothing to act on (t64)."""
rows = ["apps_text", "apps_add"]
if self._editable_apps(stored_only=True):
rows += ["apps_edit", "apps_remove"]
rows.append("menu_again")
return rows
def _detected_apps(self) -> dict:
"""The apps the device reported, ``{package: {name, icon}}`` (t64).
Straight from the t57 cache — no sweep, no shell. The name may be empty:
the user's Shield exposes no device label at all, and a package whose
``pm dump`` failed falls back to its id, which is NOT a name (see
``_editable_apps``).
"""
detected: dict = {}
for app in self._discovered_apps():
package = str(app.get("package") or "").strip()
if not package:
continue
detected.setdefault(
package,
{
"name": str(app.get("name") or "").strip(),
"icon": str(app.get("icon") or "").strip(),
},
)
return detected
def _editable_apps(self, *, stored_only: bool = False) -> dict:
"""Every app the user can act on, ``{package: {name, icon, stored}}``.
Saved rows first, then the detected ones the map does not mention — the
same union the text field's suggestion carries, so the dropdown lists
exactly what the table offers. A detected app whose name is empty (or is
just its own id) is NOT labelled twice: it is labelled as unnamed, which
is what makes it visibly editable instead of looking like a real name.
"""
icons = self._options_dict.get(CONF_APP_ICON_MAP) or {}
apps: dict = {}
for package, name in self._stored_apps().items():
apps[package] = {
"name": name,
"icon": str(icons.get(package) or ""),
"stored": True,
}
if stored_only:
return apps
for package, row in self._detected_apps().items():
if package in apps:
continue
apps[package] = {
"name": row["name"] if row["name"] != package else "",
# the device icon is an ``mdi:`` name, which the icon column does
# NOT accept (it feeds entity_picture), so it is never prefilled
"icon": "",
"stored": False,
}
return apps
def _app_label(self, package: str, row: dict) -> str:
"""How one app reads in a dropdown or in the menu's note (t64)."""
if row.get("name"):
return f"{row['name']} ({package})"
return f"{package} (no name yet)"
def _apps_placeholders(self) -> dict:
"""What the Apps menu says about the apps it offers to act on."""
apps = self._editable_apps()
if not apps:
return {"apps": "no apps known yet — use Discover installed apps, or add one"}
labels = []
for package, row in apps.items():
label = self._app_label(package, row)
# a detected-but-unsaved app is marked, so the user can see what the
# map does not own yet (and which name is worth fixing)
labels.append(label if row["stored"] else f"{label} [detected]")
shown = ", ".join(labels[:8])
if len(labels) > 8:
shown += f", … (+{len(labels) - 8} more)"
count = f"{len(labels)} app" if len(labels) == 1 else f"{len(labels)} apps"
return {"apps": f"{count} known — {shown}"}
def _app_selector(self, apps: dict):
"""The "which app" selector for the rows it is given (t64)."""
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=package, label=self._app_label(package, row)
)
for package, row in apps.items()
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
def _show_app_choose_form(self, step_id: str, apps: dict, errors=None):
"""Ask WHICH app, one step before editing or removing it (t64)."""
return self._show_sub_form(
step_id,
{vol.Required(APP_FIELD_PACKAGE): self._app_selector(apps)},
errors,
)
def _show_app_row_form(
self,
step_id: str,
*,
package: str | None,
package_value: str = "",
name: str = "",
icon: str = "",
errors=None,
placeholders: dict | None = None,
):
"""The add/edit form for ONE row: package (add only), name, icon (t64).
The values are PREFILL VALUES, not voluptuous defaults: HA puts a
suggested value on the wire (``description.suggested_value``) so the
browser renders the field filled in, while a ``default=`` would only be
substituted during validation. The edit form is the one place the user has
to SEE the stored row, so the prefill has to travel that way.
"""
fields: dict = {}
prefill: dict = {APP_FIELD_NAME: name, APP_FIELD_ICON: icon}
if package is None:
fields[vol.Required(APP_FIELD_PACKAGE)] = str
prefill[APP_FIELD_PACKAGE] = package_value
fields[vol.Required(APP_FIELD_NAME)] = str
fields[vol.Optional(APP_FIELD_ICON)] = str
return self._show_sub_form(
step_id, fields, errors, override=prefill, placeholders=placeholders
)
def _write_app_row(self, package: str, name: str, icon: str) -> None:
"""Set ONE row; every other row (and its icon) stays untouched (t64)."""
options = self._options_dict
app_map = dict(options.get(CONF_APP_NAME_MAP) or {})
icon_map = dict(options.get(CONF_APP_ICON_MAP) or {})
app_map[package] = name
if icon:
icon_map[package] = icon
else:
icon_map.pop(package, None)
options[CONF_APP_NAME_MAP] = app_map
options[CONF_APP_ICON_MAP] = icon_map
def _drop_app_row(self, package: str) -> None:
"""Remove ONE row, keeping the icon column in step (t64)."""
options = self._options_dict
app_map = dict(options.get(CONF_APP_NAME_MAP) or {})
icon_map = dict(options.get(CONF_APP_ICON_MAP) or {})
app_map.pop(package, None)
icon_map.pop(package, None)
options[CONF_APP_NAME_MAP] = app_map
options[CONF_APP_ICON_MAP] = icon_map
async def async_step_apps_text(self, user_input=None):
"""The whole table as ONE multiline text field (t64)."""
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)
return await self.async_step_apps()
return self._show_apps_text_form(errors)
async def async_step_apps_add(self, user_input=None):
"""ADD one row: package + name + optional icon (t64)."""
errors: dict = {}
submitted = dict(user_input or {})
package = str(submitted.get(APP_FIELD_PACKAGE) or "").strip()
name = str(submitted.get(APP_FIELD_NAME) or "").strip()
icon = str(submitted.get(APP_FIELD_ICON) or "").strip()
if user_input is not None:
if not _valid_app_row(package, name, icon):
errors["base"] = "invalid_app_row"
else:
self._write_app_row(package, name, icon)
return await self.async_step_apps()
return self._show_app_row_form(
"apps_add",
package=None,
package_value=package,
name=name,
icon=icon,
errors=errors,
)
async def async_step_apps_edit(self, user_input=None):
"""EDIT: pick an app (detected or saved), then set its name and icon (t64).
The dropdown lists EVERY app the user could act on — the saved rows plus
the ones discovery found — because that is what the request asked for
("selector to view the already available detected apps") and because a
detected app is exactly the one whose name needs fixing: picking it opens
the form prefilled with what is known (nothing, for a label-less Shield)
and saving stores the row.
"""
apps = self._editable_apps()
if user_input is not None:
package = str(user_input.get(APP_FIELD_PACKAGE) or "")
if package in apps:
self._editing_package = package
return await self.async_step_apps_edit_form()
if not apps:
return await self.async_step_apps() # nothing left to choose from
return self._show_app_choose_form(
"apps_edit", apps, {"base": "unknown_app_row"}
)
return self._show_app_choose_form("apps_edit", apps)
async def async_step_apps_edit_form(self, user_input=None):
"""The PREFILLED form for the app chosen in ``apps_edit`` (t64).
The package id IS the row's identity, so it is not editable here: the
form shows it (in the description) and edits the name and icon. Use
Add, Remove or the whole-list field to change the id itself.
"""
package = getattr(self, "_editing_package", None)
apps = self._editable_apps()
if not package or package not in apps:
return await self.async_step_apps() # the row is gone: no dead end
row = apps[package]
errors: dict = {}
name = row["name"]
icon = row["icon"]
if user_input is not None:
name = str(user_input.get(APP_FIELD_NAME) or "").strip()
icon = str(user_input.get(APP_FIELD_ICON) or "").strip()
if not _valid_app_row(package, name, icon):
errors["base"] = "invalid_app_row"
else:
self._write_app_row(package, name, icon)
self._editing_package = None
return await self.async_step_apps()
return self._show_app_row_form(
"apps_edit_form",
package=package,
name=name,
icon=icon,
errors=errors,
placeholders={"app": self._app_label(package, row)},
)
async def async_step_apps_remove(self, user_input=None):
"""REMOVE: choose which SAVED app, then confirm it explicitly (t64).
Only rows the map owns can be removed: a detected app that was never
saved has no line to delete, and it would come back with the next sweep.
"""
apps = self._editable_apps(stored_only=True)
if user_input is not None:
package = str(user_input.get(APP_FIELD_PACKAGE) or "")
if package in apps:
self._removing_package = package
return await self.async_step_apps_remove_confirm()
if not apps:
return await self.async_step_apps() # nothing left to choose from
return self._show_app_choose_form(
"apps_remove", apps, {"base": "unknown_app_row"}
)
return self._show_app_choose_form("apps_remove", apps)
async def async_step_apps_remove_confirm(self, user_input=None):
"""The confirmation MENU: dropping a row takes a second, explicit act (t64)."""
package = getattr(self, "_removing_package", None)
apps = self._editable_apps(stored_only=True)
if not package or package not in apps:
return await self.async_step_apps()
return self.async_show_menu(
step_id="apps_remove_confirm",
menu_options=["apps_remove_yes", "apps_remove_no"],
description_placeholders={
"app": self._app_label(package, apps[package])
},
)
async def async_step_apps_remove_yes(self, user_input=None):
"""Confirmed: drop the row and come back to the editor (t64)."""
if getattr(self, "_removing_package", None):
self._drop_app_row(self._removing_package)
self._removing_package = None
return await self.async_step_apps()
async def async_step_apps_remove_no(self, user_input=None):
"""Cancelled: nothing changes, and the editor comes back (t64)."""
self._removing_package = None
return await self.async_step_apps()
def _discovered_apps(self) -> list[dict]:
"""The apps the device reported, from the last background sweep (t57).
@@ -1446,16 +1819,21 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
return []
return manager.installed_apps()
def _show_apps_form(self, errors=None):
def _show_apps_text_form(self, errors=None):
"""The whole table as TEXT in a MULTILINE field (t56/t64).
The field is a multiline ``TextSelector``, not a bare ``str``: HA renders
a selector-less string field as a SINGLE-LINE input (its docs: "If no
selector is defined, a text input for a single line will be shown"), which
is why the live page showed the stored map as one long flattened line with
no way to add a row. One row per line, exactly the stored format:
``package=name`` with an optional ``|icon``.
The suggestion carries TEXT in every case (never the stored dict — that
was the [object Object] bug) and is the user's own rows PLUS everything
discovery found on the device.
"""
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 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,
}
discovered = self._discovered_apps()
suggested = _apps_table_text(
stored.get(CONF_APP_NAME_MAP) or {},
@@ -1464,8 +1842,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
)
icons = sorted({app.get("icon") for app in discovered if app.get("icon")})
return self._show_sub_form(
"apps",
schema_dict,
"apps_text",
{vol.Optional(CONF_APP_NAME_MAP): _build_selectors()["app_map"]},
errors,
override={CONF_APP_NAME_MAP: suggested},
placeholders={"icons": ", ".join(icons) or "none known"},