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:
1 parent
eb62011ded
commit
46738063aa
8 files changed
+1369
-81
No files matched your search
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user