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
@@ -312,7 +312,7 @@ and only **Save and close** commits:
|
||||
| --- | --- |
|
||||
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch |
|
||||
| **Receiver** | the receiver/soundbar entity and the input it uses |
|
||||
| **Apps** | the apps table — one row per app (see below) |
|
||||
| **Apps** | the apps editor — add, edit or remove one app, or paste the whole list (see below) |
|
||||
| **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER |
|
||||
| **Save and close** | writes the merged options and reloads the integration |
|
||||
|
||||
@@ -331,7 +331,29 @@ the menu's description names what is still to pair.
|
||||
|
||||
### The Apps table
|
||||
|
||||
The **Apps** row is an editable table. One app per line, in the documented format:
|
||||
The **Apps** row opens a small editor rather than one long form. Home Assistant has no
|
||||
*table* selector, so a table is edited the way HA edits one — **one row per step**:
|
||||
|
||||
| Row | What it does |
|
||||
| --- | --- |
|
||||
| **Edit the whole list as text** | the multiline field below: paste, rename or delete rows in one go |
|
||||
| **Add an app** | package id, name to show, and an optional icon |
|
||||
| **Edit an app** | pick one of the apps the Shield reported, or one you saved; its name and icon open prefilled |
|
||||
| **Remove an app** | pick a saved row, then confirm it on the next step |
|
||||
| **Back to the main menu** | leave the editor (nothing is written until **Save and close**) |
|
||||
|
||||
*Edit* and *Remove* are offered only while there is something to act on: a table with
|
||||
no remembered rows and no discovery yet shows just the text field and *Add an app*. The
|
||||
editor's description lists every app it knows — the rows you saved, and the ones
|
||||
discovery found, marked `[detected]`. **Edit** acts on both (that is the point: the
|
||||
Shield exposes no app labels of its own, so a detected app reads as `name (package id)`
|
||||
or as `package id (no name yet)`, and picking it is how you give it a name — saving
|
||||
stores it as one of your own rows). **Remove** acts only on rows you saved, because a
|
||||
detected app has no line to delete (it would simply come back with the next sweep).
|
||||
Removing a row never uninstalls anything from the Shield — the app simply stops being
|
||||
offered. Rows you do not touch are left exactly as they were.
|
||||
|
||||
The text field is one app per line, in the same format the integration stores:
|
||||
|
||||
```
|
||||
ar.tvplayer.tv=TiviMate
|
||||
@@ -340,16 +362,14 @@ com.netflix.ninja=Netflix
|
||||
```
|
||||
|
||||
`package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or
|
||||
a `camera.*` entity id — see *Sources* below). The field always opens carrying the
|
||||
current rows, so adding, renaming or deleting an app is an edit to that text:
|
||||
|
||||
- **add a row** — type a new line
|
||||
- **edit a row** — change its name, icon or package id
|
||||
- **remove a row** — delete its line
|
||||
a `camera.*` entity id — see *Sources* below). The field opens carrying the current
|
||||
rows, and it is a **multiline** input: earlier versions rendered it as a single-line
|
||||
string, so the whole table arrived as one flattened line with nowhere to add a row.
|
||||
|
||||
A blank field clears the table (and the integration falls back to its built-in app
|
||||
list). A malformed row — no `=`, or an empty name — is rejected with an error on the
|
||||
page rather than being half-saved.
|
||||
list). A malformed row — no `=`, an empty name, a space in the package id or in the
|
||||
icon — is rejected with an error on the page rather than being half-saved, and the
|
||||
*Add* and *Edit* forms enforce exactly the same rule.
|
||||
|
||||
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
|
||||
speaker that only knows on and off is a valid receiver. Playback then **ensures
|
||||
@@ -442,8 +462,10 @@ 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:
|
||||
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu and
|
||||
the table is rebuilt from two ADB queries — and the row then **says what happened** (see
|
||||
*What the Discover row tells you* below), because an action that appears to do nothing is
|
||||
indistinguishable from a broken integration:
|
||||
|
||||
| Command | What it returns | How it is treated |
|
||||
| --- | --- | --- |
|
||||
@@ -486,6 +508,21 @@ the menu says why. A sweep that **could not run** is not cached as an answer: it
|
||||
recorded as `unknown` and retried, so a Shield that was asleep at startup is picked up
|
||||
by the next refresh instead of reporting an empty device for six hours.
|
||||
|
||||
#### What the Discover row tells you
|
||||
|
||||
The Discover row is an *action*: it runs a sweep and returns you to the same menu with a
|
||||
one-line verdict, which replaces the usual "not discovered yet" note:
|
||||
|
||||
| Verdict | What it means |
|
||||
| --- | --- |
|
||||
| `N apps found on the Shield — the table is updated.` | the sweep ran, and the editor's text field now carries the discovered rows |
|
||||
| `the Shield reported no installed apps — the table keeps your saved rows.` | the sweep ran and found nothing to add; your rows are untouched |
|
||||
| `the discovery sweep could not reach the Shield — check the ADB connection; your saved rows are unchanged.` | the sweep failed; nothing was cached as a result and nothing you wrote was lost |
|
||||
| `ADB is not set up for this entry — nothing could be discovered.` | this entry has no ADB manager (a remote-protocol-only setup), so there is nothing to sweep |
|
||||
|
||||
The action is best-effort by design: whatever the verdict, the Configure menu still opens
|
||||
and the editor still works on the rows you wrote.
|
||||
|
||||
**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`, `unknown`,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
|
||||
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
|
||||
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield is already set up.",
|
||||
@@ -233,7 +235,18 @@
|
||||
},
|
||||
"apps": {
|
||||
"title": "Apps",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
@@ -241,6 +254,60 @@
|
||||
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"title": "Scripts",
|
||||
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
|
||||
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
|
||||
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield is already set up.",
|
||||
@@ -233,7 +235,18 @@
|
||||
},
|
||||
"apps": {
|
||||
"title": "Apps",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
@@ -241,6 +254,60 @@
|
||||
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"title": "Scripts",
|
||||
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||
|
||||
@@ -80,6 +80,30 @@ def install_selector_faces(selector_module) -> None:
|
||||
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||
selector_module.SelectSelectorConfig = _Config
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
# t64: the apps table is a MULTILINE text field, and the row editor picks an
|
||||
# app out of a select whose options are label/value pairs. Both are real HA
|
||||
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
|
||||
selector_module.SelectOptionDict = dict
|
||||
selector_module.TextSelector = TextSelectorStub
|
||||
selector_module.TextSelectorConfig = _Config
|
||||
|
||||
|
||||
class TextSelectorStub:
|
||||
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
|
||||
|
||||
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
|
||||
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
|
||||
the browser render a textarea, which is the t64 fix, so it is inspectable
|
||||
here the same way the entity selectors' domains are.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("Value is not a string")
|
||||
return data
|
||||
|
||||
|
||||
class MediaPlayerState(enum.StrEnum):
|
||||
|
||||
@@ -255,6 +255,24 @@ async def open_scripts(hass, entry):
|
||||
return manager, scripts
|
||||
|
||||
|
||||
async def open_apps_menu(hass, entry):
|
||||
"""Open the Apps editor (t64: a MENU of row actions) through the real menu."""
|
||||
manager, menu = await open_options(hass, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
assert apps["type"] == "menu", apps
|
||||
return manager, apps
|
||||
|
||||
|
||||
async def open_apps_text(hass, entry):
|
||||
"""Open the Apps editor's TEXT step — the whole-list multiline field (t64)."""
|
||||
manager, apps = await open_apps_menu(hass, entry)
|
||||
text = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_text"}
|
||||
)
|
||||
assert text["type"] == "form", text
|
||||
return manager, text
|
||||
|
||||
|
||||
class TestRealMenuFlow:
|
||||
"""The options page is a real menu with real sub-screens."""
|
||||
|
||||
@@ -286,7 +304,11 @@ class TestRealMenuFlow:
|
||||
opened = await _manager.async_configure(
|
||||
fresh["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] == "form", row
|
||||
# a sub-screen is a FORM — except Apps, which is a MENU of row
|
||||
# actions: HA has no table selector, so a table is edited one row per
|
||||
# step (t64), and every row it offers has to be dispatchable too
|
||||
expected = "menu" if row == "apps" else "form"
|
||||
assert opened["type"] == expected, (row, opened)
|
||||
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
||||
# step_id must be the menu row id (the method name)
|
||||
assert opened["step_id"] == row
|
||||
@@ -310,8 +332,7 @@ class TestRealAppsField:
|
||||
|
||||
async def test_the_apps_suggestion_is_the_serialized_text(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
suggested_values = suggested(apps["data_schema"])
|
||||
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
||||
@@ -325,8 +346,7 @@ class TestRealAppsField:
|
||||
|
||||
async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
fields = field_list(apps["data_schema"])
|
||||
assert [f["name"] for f in fields] == ["app_name_map"]
|
||||
@@ -334,17 +354,22 @@ class TestRealAppsField:
|
||||
|
||||
async def test_a_table_row_round_trips_through_the_real_flow(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
saved = await manager.async_configure(
|
||||
apps["flow_id"],
|
||||
{"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"},
|
||||
)
|
||||
assert saved["type"] == "menu" # back at the menu, nothing committed yet
|
||||
assert saved["type"] == "menu" # back at the APPS editor, nothing committed
|
||||
assert saved["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
assert back["type"] == "menu" and back["step_id"] == "init"
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
@@ -506,13 +531,22 @@ class TestRealNoLostEdits:
|
||||
apps = await manager.async_configure(
|
||||
after_scripts["flow_id"], {"next_step_id": "apps"}
|
||||
)
|
||||
after_apps = await manager.async_configure(
|
||||
apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||
assert apps["type"] == "menu" and apps["step_id"] == "apps"
|
||||
text = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_text"}
|
||||
)
|
||||
assert after_apps["type"] == "menu"
|
||||
after_apps = await manager.async_configure(
|
||||
text["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||
)
|
||||
assert after_apps["type"] == "menu" and after_apps["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
assert back["type"] == "menu" and back["step_id"] == "init"
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "finish"}
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
data = created["data"]
|
||||
@@ -815,3 +849,347 @@ class TestRealSetupMenu:
|
||||
assert menu["type"] == "menu"
|
||||
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||
assert "ADB" in note and "remote protocol" in note
|
||||
|
||||
|
||||
# ------------------------------------------- t64: the apps editor (real HA)
|
||||
#
|
||||
# Both live reports are about this screen: the map arrived as ONE LONG LINE with
|
||||
# rows that could not be added, and "Discover installed apps seems to load then do
|
||||
# nothing". The fixes are pinned here, over the REAL FlowManager, because that is
|
||||
# where the field list the browser renders and the exact ``user_input`` a
|
||||
# submission produces are decided. What is NOT provable here: how the field LOOKS.
|
||||
# There is no browser, so "multiline" is proven through the serialised selector
|
||||
# (``selector.text.multiline``) — the value the frontend actually reads.
|
||||
|
||||
|
||||
class TestRealAppsMultilineField:
|
||||
"""t64: the apps field is a MULTILINE text field — the live bug's cause."""
|
||||
|
||||
async def test_the_rendered_field_carries_the_multiline_text_selector(self, ha):
|
||||
entry = make_entry(ha, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
fields = {f["name"]: f for f in field_list(apps["data_schema"])}
|
||||
assert list(fields) == ["app_name_map"]
|
||||
# A bare `str` field with no selector renders as a SINGLE-LINE input (HA
|
||||
# docs), which is why the live page showed the map flattened and refused
|
||||
# new lines. The field must carry a real selector, and it is the text one;
|
||||
# a selector-less field would have no ``selector`` key at all.
|
||||
assert fields["app_name_map"]["selector"] == {
|
||||
"text": {"multiline": True, "multiple": False}
|
||||
}
|
||||
|
||||
async def test_a_saved_page_keeps_one_row_per_line_and_reopens_unchanged(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
page = "com.a=Alpha\ncom.b=Beta|http://icons.local/b.png\ncom.c=Gamma"
|
||||
saved = await manager.async_configure(apps["flow_id"], {"app_name_map": page})
|
||||
back = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta",
|
||||
"com.c": "Gamma",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b.png"}
|
||||
|
||||
# reopen over the SAVED options: the same three lines come back, in order
|
||||
reopened = dict(entry.options)
|
||||
reopened.update(created["data"])
|
||||
object.__setattr__(entry, "_options", reopened)
|
||||
_manager, apps2 = await open_apps_text(ha, entry)
|
||||
assert suggested(apps2["data_schema"])["app_name_map"] == page
|
||||
|
||||
|
||||
class TestRealAppsRowEditor:
|
||||
"""t64: add / edit / remove ONE row — HA has no table selector."""
|
||||
|
||||
async def test_the_editor_offers_only_the_rows_that_can_do_something(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
_manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
# nothing stored AND nothing detected: no edit/remove row could do
|
||||
# anything, so none is offered (t64)
|
||||
assert list(apps["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
|
||||
assert "no apps known yet" in apps["description_placeholders"]["apps"]
|
||||
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
|
||||
)
|
||||
_manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
assert list(apps["menu_options"]) == [
|
||||
"apps_text",
|
||||
"apps_add",
|
||||
"apps_edit",
|
||||
"apps_remove",
|
||||
"menu_again",
|
||||
]
|
||||
assert apps["description_placeholders"]["apps"] == (
|
||||
"2 apps known — Alpha (com.a), Beta (com.b)"
|
||||
)
|
||||
|
||||
async def test_the_dropdown_carries_the_detected_apps_and_names_them(self, ha):
|
||||
"""t64: the picked-from list is the detected list, named where known.
|
||||
|
||||
The user's own Shield exposes no device label at all, so the unnamed case
|
||||
is the common one: it must read as unnamed (not as its own id twice), it
|
||||
must still be pickable, and saving a name for it must store the row.
|
||||
"""
|
||||
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
|
||||
entry.runtime_data = TestRealDiscoverFeedback._Manager(
|
||||
[
|
||||
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
|
||||
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
|
||||
]
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
note = apps["description_placeholders"]["apps"]
|
||||
assert note.startswith("3 apps known — ")
|
||||
assert "Kodi (org.xbmc.kodi) [detected]" in note
|
||||
assert "com.unnamed.tv (no name yet) [detected]" in note
|
||||
assert "Alpha (com.a)" in note # a saved row keeps the user's name
|
||||
|
||||
choose = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_edit"})
|
||||
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
|
||||
assert options == [
|
||||
{"value": "com.a", "label": "Alpha (com.a)"},
|
||||
{"value": "org.xbmc.kodi", "label": "Kodi (org.xbmc.kodi)"},
|
||||
{"value": "com.unnamed.tv", "label": "com.unnamed.tv (no name yet)"},
|
||||
]
|
||||
|
||||
form = await manager.async_configure(
|
||||
choose["flow_id"], {"app_package": "com.unnamed.tv"}
|
||||
)
|
||||
assert form["step_id"] == "apps_edit_form"
|
||||
assert "com.unnamed.tv (no name yet)" in form["description_placeholders"]["app"]
|
||||
assert suggested(form["data_schema"])["app_name"] == "" # nothing invented
|
||||
assert suggested(form["data_schema"])["app_icon"] == "" # never the mdi: icon
|
||||
|
||||
edited = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
{"app_name": "Unnamed TV", "app_icon": "http://icons.local/u.png"},
|
||||
)
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.unnamed.tv": "Unnamed TV",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.unnamed.tv": "http://icons.local/u.png"}
|
||||
|
||||
async def test_add_persists_one_row_and_leaves_the_others_alone(self, ha):
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha"}, "host": "10.0.0.9"}
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
|
||||
assert add["type"] == "form"
|
||||
assert add["step_id"] == "apps_add"
|
||||
assert field_names(add["data_schema"]) == ["app_package", "app_name", "app_icon"]
|
||||
|
||||
edited = await manager.async_configure(
|
||||
add["flow_id"],
|
||||
{"app_package": "com.b", "app_name": "Beta", "app_icon": ""},
|
||||
)
|
||||
assert edited["type"] == "menu"
|
||||
assert edited["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {"com.a": "Alpha", "com.b": "Beta"}
|
||||
assert created["data"]["host"] == "10.0.0.9" # nothing else moved
|
||||
|
||||
async def test_an_invalid_row_is_refused_with_a_message(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
|
||||
|
||||
refused = await manager.async_configure(
|
||||
add["flow_id"],
|
||||
{"app_package": "has spaces", "app_name": "Nope", "app_icon": ""},
|
||||
)
|
||||
|
||||
assert refused["type"] == "form"
|
||||
assert refused["step_id"] == "apps_add"
|
||||
assert refused["errors"] == {"base": "invalid_app_row"}
|
||||
|
||||
async def test_edit_lists_the_stored_rows_and_prefills_the_chosen_one(self, ha):
|
||||
entry = make_entry(
|
||||
ha,
|
||||
options={
|
||||
"app_name_map": {"com.a": "Alpha", "com.b": "Beta"},
|
||||
"app_icon_map": {"com.b": "http://icons.local/b.png"},
|
||||
},
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
choose = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_edit"}
|
||||
)
|
||||
assert choose["type"] == "form"
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
|
||||
assert options == [
|
||||
{"value": "com.a", "label": "Alpha (com.a)"},
|
||||
{"value": "com.b", "label": "Beta (com.b)"},
|
||||
]
|
||||
|
||||
form = await manager.async_configure(choose["flow_id"], {"app_package": "com.b"})
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps_edit_form"
|
||||
assert "Beta (com.b)" in form["description_placeholders"]["app"]
|
||||
# PREFILLED from the stored row — name and icon both
|
||||
assert suggested(form["data_schema"])["app_name"] == "Beta"
|
||||
assert suggested(form["data_schema"])["app_icon"] == "http://icons.local/b.png"
|
||||
|
||||
edited = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
{"app_name": "Beta (living room)", "app_icon": "http://icons.local/b2.png"},
|
||||
)
|
||||
assert edited["type"] == "menu" and edited["step_id"] == "apps"
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta (living room)",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b2.png"}
|
||||
|
||||
async def test_remove_confirms_first_and_a_cancel_keeps_the_row(self, ha):
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
choose = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_remove"}
|
||||
)
|
||||
assert choose["type"] == "form" and choose["step_id"] == "apps_remove"
|
||||
|
||||
confirm = await manager.async_configure(choose["flow_id"], {"app_package": "com.a"})
|
||||
assert confirm["type"] == "menu"
|
||||
assert confirm["step_id"] == "apps_remove_confirm"
|
||||
assert set(confirm["menu_options"]) == {"apps_remove_yes", "apps_remove_no"}
|
||||
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
|
||||
|
||||
cancelled = await manager.async_configure(
|
||||
confirm["flow_id"], {"next_step_id": "apps_remove_no"}
|
||||
)
|
||||
assert cancelled["type"] == "menu" and cancelled["step_id"] == "apps"
|
||||
|
||||
# ...and the confirmed path drops exactly that row
|
||||
choose2 = await manager.async_configure(
|
||||
cancelled["flow_id"], {"next_step_id": "apps_remove"}
|
||||
)
|
||||
confirm2 = await manager.async_configure(
|
||||
choose2["flow_id"], {"app_package": "com.a"}
|
||||
)
|
||||
after = await manager.async_configure(
|
||||
confirm2["flow_id"], {"next_step_id": "apps_remove_yes"}
|
||||
)
|
||||
assert after["type"] == "menu" and after["step_id"] == "apps"
|
||||
back = await manager.async_configure(
|
||||
after["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {"com.b": "Beta"}
|
||||
|
||||
|
||||
class TestRealDiscoverFeedback:
|
||||
"""t64: the Discover row reports WHAT IT DID — never a silent no-op."""
|
||||
|
||||
class _Manager:
|
||||
"""A stand-in connection manager with a canned discovered table."""
|
||||
|
||||
def __init__(self, rows=None, connected=True, fail=False):
|
||||
self.rows = list(rows or [])
|
||||
self.connected = connected
|
||||
self.fail = fail
|
||||
self.refresh_calls: list[bool] = []
|
||||
|
||||
def installed_apps(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
async def async_refresh_installed_apps(self, force=False):
|
||||
self.refresh_calls.append(force)
|
||||
if self.fail:
|
||||
raise RuntimeError("adb went away")
|
||||
return self.installed_apps()
|
||||
|
||||
async def _discover(self, ha, entry):
|
||||
manager, menu = await open_options(ha, entry)
|
||||
return manager, await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "discover"}
|
||||
)
|
||||
|
||||
async def test_an_unreachable_sweep_says_so(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
entry.runtime_data = self._Manager(connected=False, fail=True)
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
assert after["type"] == "menu" and after["step_id"] == "init"
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert "could not reach" in note and "unchanged" in note
|
||||
|
||||
async def test_finding_apps_reports_the_count(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager = self._Manager(
|
||||
[
|
||||
{"package": "com.a", "name": "Alpha", "icon": "mdi:alpha"},
|
||||
{"package": "com.b", "name": "Beta", "icon": "mdi:beta"},
|
||||
]
|
||||
)
|
||||
entry.runtime_data = manager
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert note.startswith("2 apps found on the Shield")
|
||||
assert "updated" in note
|
||||
assert manager.refresh_calls == [True] # explicit force, exactly once
|
||||
|
||||
async def test_finding_nothing_says_the_rows_are_kept(self, ha):
|
||||
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
|
||||
entry.runtime_data = self._Manager([])
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert "no installed apps" in note and "keeps your" in note
|
||||
|
||||
async def test_without_a_manager_it_says_adb_is_not_set_up(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert note == "ADB is not set up for this entry — nothing could be discovered."
|
||||
+339
-25
@@ -36,6 +36,7 @@ from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_APP_ICON_MAP,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
@@ -1526,15 +1527,27 @@ class TestCopyContract:
|
||||
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
async def walk(step: str) -> dict:
|
||||
"""The rendered screens from ``step`` down, keyed by the i18n step id."""
|
||||
opened = await getattr(flow, f"async_step_{step}")(None)
|
||||
if opened["type"] == "menu":
|
||||
nested: dict = {step: ("menu", opened)}
|
||||
for row in opened["menu_options"]:
|
||||
if row in OPTIONS_ACTION_ROWS:
|
||||
continue
|
||||
nested.update(await walk(row))
|
||||
return nested
|
||||
return {step: ("form", opened)}
|
||||
|
||||
menu = await flow.async_step_init(None)
|
||||
# the rendered sub-screens, keyed by the i18n step id the menu row maps to
|
||||
rendered = {
|
||||
step: vol_mirror.declared_fields(
|
||||
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
|
||||
)
|
||||
for step in menu["menu_options"]
|
||||
if step not in ("finish", "discover")
|
||||
}
|
||||
# t64: the Apps sub-screen is itself a MENU of row actions, so the walk
|
||||
# goes as deep as the screen does — a nested step is checked exactly like
|
||||
# a top-level one, and every row a nested menu offers must have a label.
|
||||
rendered: dict = {}
|
||||
for step in menu["menu_options"]:
|
||||
if step in OPTIONS_ACTION_ROWS:
|
||||
continue
|
||||
rendered.update(await walk(step))
|
||||
|
||||
for file in (_strings(), _en_strings()):
|
||||
menu_step = file["options"]["step"]["init"]
|
||||
@@ -1546,12 +1559,18 @@ class TestCopyContract:
|
||||
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
|
||||
|
||||
steps = file["options"]["step"]
|
||||
for step, fields in rendered.items():
|
||||
for step, (kind, opened) in rendered.items():
|
||||
# a sub-screen's i18n step key is its menu row id (HA dispatches
|
||||
# a submitted form by its own step_id, so the two must match)
|
||||
assert steps[step]["title"], step
|
||||
assert steps[step]["description"], step
|
||||
for field in fields:
|
||||
if kind == "menu":
|
||||
for row in opened["menu_options"]:
|
||||
assert row in steps[step].get("menu_options", {}), (
|
||||
f"{step} row {row} has no label"
|
||||
)
|
||||
continue
|
||||
for field in vol_mirror.declared_fields(opened["data_schema"]):
|
||||
assert field in steps[step]["data"], f"{step}.{field} has no label"
|
||||
|
||||
|
||||
@@ -1586,7 +1605,9 @@ OPTIONS_SUBSTEP_FIELDS = {
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
},
|
||||
"receiver": {"receiver_entity", "receiver_input"},
|
||||
"apps": {"app_name_map", "app_icon_map"},
|
||||
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu
|
||||
# of row actions), so the field that owns the map is `apps_text`.
|
||||
"apps_text": {"app_name_map", "app_icon_map"},
|
||||
"scripts": {
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
@@ -1595,6 +1616,24 @@ OPTIONS_SUBSTEP_FIELDS = {
|
||||
},
|
||||
}
|
||||
|
||||
#: t64: the Apps editor's rows live one level deeper — the Apps sub-screen is a
|
||||
#: MENU of row actions, exactly as the browser navigates it: open the parent row,
|
||||
#: then the row the user picked.
|
||||
OPTIONS_NESTED_STEPS = {
|
||||
"apps_text": "apps",
|
||||
"apps_add": "apps",
|
||||
"apps_edit": "apps",
|
||||
"apps_edit_form": "apps",
|
||||
"apps_remove": "apps",
|
||||
"apps_remove_confirm": "apps",
|
||||
}
|
||||
|
||||
#: Menu rows that ACT rather than render a form: HA dispatches them with no input,
|
||||
#: so there is no data_schema and no field labels to check.
|
||||
OPTIONS_ACTION_ROWS = frozenset(
|
||||
{"discover", "finish", "menu_again", "apps_remove_yes", "apps_remove_no"}
|
||||
)
|
||||
|
||||
|
||||
def _options_substep_of(key: str) -> str:
|
||||
for step, fields in OPTIONS_SUBSTEP_FIELDS.items():
|
||||
@@ -1619,11 +1658,20 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
grouped.setdefault(_options_substep_of(key), {})[key] = value
|
||||
result = await flow.async_step_init(None)
|
||||
for step, sub_payload in grouped.items():
|
||||
parent = OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], (
|
||||
f"{parent} is not offered by the menu: {result['menu_options']}"
|
||||
)
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], (
|
||||
f"{step} is not offered by the menu: {result['menu_options']}"
|
||||
)
|
||||
result = await getattr(flow, f"async_step_{step}")(sub_payload)
|
||||
if save:
|
||||
# t64: a nested row (the apps table) returns to the APPS menu, so walk
|
||||
# back the way the "Back to the main menu" row does before committing.
|
||||
result = await flow.async_step_menu_again()
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await flow.async_step_finish(None)
|
||||
return result
|
||||
@@ -1642,6 +1690,10 @@ def _all_script_fields(value):
|
||||
async def _options_open(flow, step: str):
|
||||
"""Choose a sub-screen from the menu and open its form."""
|
||||
result = await flow.async_step_init(None)
|
||||
parent = OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
return await getattr(flow, f"async_step_{step}")(None)
|
||||
|
||||
@@ -2036,16 +2088,22 @@ class TestOptionsMenu:
|
||||
assert hasattr(flow, f"async_step_{row}"), row
|
||||
if row in action_rows:
|
||||
continue
|
||||
form = await getattr(flow, f"async_step_{row}")(None)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == row, form["step_id"]
|
||||
opened = await getattr(flow, f"async_step_{row}")(None)
|
||||
# a sub-screen is a FORM — or, for Apps (t64), a MENU of row actions
|
||||
assert opened["type"] in ("form", "menu"), (row, opened)
|
||||
assert opened["step_id"] == row, opened["step_id"]
|
||||
if opened["type"] == "menu":
|
||||
# a nested menu's rows are dispatched by name as well
|
||||
for nested in opened["menu_options"]:
|
||||
assert hasattr(flow, f"async_step_{nested}"), (row, nested)
|
||||
|
||||
async def test_every_sub_screen_is_reachable_and_returns_to_the_menu(self, tmp_path):
|
||||
flow = _options_flow(tmp_path)
|
||||
|
||||
for row in ("connection", "receiver", "apps", "scripts"): # sub-screens
|
||||
opened = await _options_open(flow, row)
|
||||
assert opened["type"] == "form"
|
||||
# a sub-screen is a form, except Apps: a MENU of row actions (t64)
|
||||
assert opened["type"] in ("form", "menu"), (row, opened)
|
||||
# a submission that changes nothing still returns to the menu
|
||||
back = await getattr(flow, f"async_step_{row}")({})
|
||||
assert back["type"] == "menu", (row, back)
|
||||
@@ -2090,7 +2148,7 @@ class TestAppsTable:
|
||||
{"app_name_map": {"ar.tvplayer.tv": "TiviMate", "com.plex.hdx": "Plex"}},
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
suggested = form["data_schema"]["__suggested__"]
|
||||
|
||||
assert isinstance(suggested["app_name_map"], str)
|
||||
@@ -2111,7 +2169,7 @@ class TestAppsTable:
|
||||
|
||||
# re-open: the row (icon included) comes back in the text field
|
||||
reopened = _options_flow(tmp_path, result["data"])
|
||||
form = await _options_open(reopened, "apps")
|
||||
form = await _options_open(reopened, "apps_text")
|
||||
assert (
|
||||
form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
== "com.plex.hdx=Plex|http://icons.local/plex.png"
|
||||
@@ -2140,10 +2198,10 @@ class TestAppsTable:
|
||||
async def test_the_empty_state_renders_without_data_or_a_crash(self, tmp_path):
|
||||
flow = _options_flow(tmp_path, options={})
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps"
|
||||
assert form["step_id"] == "apps_text"
|
||||
assert form["errors"] == {}
|
||||
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
|
||||
# no discovery data yet (there is no manager in this harness), so the
|
||||
@@ -2195,7 +2253,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
],
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
text = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
|
||||
lines = text.split("\n")
|
||||
@@ -2216,7 +2274,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["data_schema"]["__suggested__"]["app_name_map"] == (
|
||||
"com.netflix.ninja=Netflix"
|
||||
@@ -2227,7 +2285,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
tmp_path, {"app_name_map": {"com.a": "A"}}, [], connected=False
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["data_schema"]["__suggested__"]["app_name_map"] == "com.a=A"
|
||||
|
||||
@@ -2235,7 +2293,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
"""A form render must never sweep — discovery is an explicit action."""
|
||||
flow, manager = self._flow_with(tmp_path, {}, [])
|
||||
|
||||
await _options_open(flow, "apps")
|
||||
await _options_open(flow, "apps_text")
|
||||
await flow.async_step_init(None)
|
||||
|
||||
assert manager.refresh_calls == []
|
||||
@@ -2292,15 +2350,271 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
{},
|
||||
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||
)
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
submitted = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
|
||||
await flow.async_step_apps({CONF_APP_NAME_MAP: submitted})
|
||||
await flow.async_step_apps_text({CONF_APP_NAME_MAP: submitted})
|
||||
result = await flow.async_step_finish(None)
|
||||
|
||||
assert result["data"][CONF_APP_NAME_MAP] == {"com.netflix.ninja": "Netflix"}
|
||||
|
||||
|
||||
#: t64: the apps ROW EDITOR's field names. They are UI only — a projection of one
|
||||
#: ``package=name[|icon]`` row — so they are asserted against the flow module's own
|
||||
#: constants (below) instead of being trusted to match.
|
||||
APP_PACKAGE = "app_package"
|
||||
APP_NAME = "app_name"
|
||||
APP_ICON = "app_icon"
|
||||
|
||||
|
||||
class TestAppsRowEditor:
|
||||
"""t64: HA has no table selector, so a row is edited one step at a time."""
|
||||
|
||||
def _flow(self, tmp_path, options=None):
|
||||
return _options_flow(tmp_path, options or {})
|
||||
|
||||
async def test_the_field_is_the_multiline_text_selector(self, tmp_path):
|
||||
"""The mechanical cause of the live report, pinned without HA.
|
||||
|
||||
A selector-less ``str`` field renders as a SINGLE-LINE input, so the map
|
||||
arrived flattened with nowhere to add a row. The field must carry the text
|
||||
selector, and it must be the MULTILINE one — the flag the frontend reads
|
||||
to render a textarea instead of a one-line box.
|
||||
"""
|
||||
flow = self._flow(tmp_path)
|
||||
form = await _options_open(flow, "apps_text")
|
||||
module = _form_module()
|
||||
assert (
|
||||
module.APP_FIELD_PACKAGE,
|
||||
module.APP_FIELD_NAME,
|
||||
module.APP_FIELD_ICON,
|
||||
) == (APP_PACKAGE, APP_NAME, APP_ICON)
|
||||
|
||||
field = form["data_schema"][CONF_APP_NAME_MAP]
|
||||
assert isinstance(field, entity_stublib.TextSelectorStub), field
|
||||
assert field.config.multiline is True
|
||||
|
||||
async def test_the_dropdown_lists_detected_apps_too_and_labels_them(self, tmp_path):
|
||||
"""t64: the entry point is the DETECTED list, named where a name is known.
|
||||
|
||||
The dropdown is what the user picks from, so it carries the saved rows AND
|
||||
the apps discovery found; an app whose device label is missing is labelled
|
||||
"(no name yet)" rather than with its own id twice, and the icon column is
|
||||
never prefilled from the device icon (that is an ``mdi:`` name, which the
|
||||
column does not accept).
|
||||
"""
|
||||
flow = _options_flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
||||
flow.config_entry.runtime_data = TestDiscoverSaysWhatHappened._Manager(
|
||||
[
|
||||
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
|
||||
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
|
||||
]
|
||||
)
|
||||
|
||||
placeholder = flow._apps_placeholders()["apps"]
|
||||
assert placeholder.startswith("3 apps known — ")
|
||||
assert "Kodi (org.xbmc.kodi) [detected]" in placeholder
|
||||
assert "com.unnamed.tv (no name yet) [detected]" in placeholder
|
||||
assert "Alpha (com.a)" in placeholder # the saved row keeps the user's name
|
||||
|
||||
choose = await flow.async_step_apps_edit(None)
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
|
||||
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.unnamed.tv"})
|
||||
suggested_values = form["data_schema"]["__suggested__"]
|
||||
assert suggested_values[APP_NAME] == "" # no device label: nothing invented
|
||||
assert suggested_values[APP_ICON] == "" # never the mdi: device icon
|
||||
|
||||
await flow.async_step_apps_edit_form(
|
||||
{APP_NAME: "Unnamed TV", APP_ICON: "http://icons.local/u.png"}
|
||||
)
|
||||
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.unnamed.tv": "Unnamed TV",
|
||||
}
|
||||
|
||||
async def test_only_the_rows_that_can_act_are_offered(self, tmp_path):
|
||||
empty = self._flow(tmp_path)
|
||||
menu = await empty.async_step_apps(None)
|
||||
|
||||
assert list(menu["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
|
||||
assert empty._apps_placeholders()["apps"].startswith("no apps known yet")
|
||||
|
||||
flow = self._flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
||||
menu = await flow.async_step_apps(None)
|
||||
|
||||
assert list(menu["menu_options"]) == [
|
||||
"apps_text",
|
||||
"apps_add",
|
||||
"apps_edit",
|
||||
"apps_remove",
|
||||
"menu_again",
|
||||
]
|
||||
assert flow._apps_placeholders()["apps"] == "1 app known — Alpha (com.a)"
|
||||
|
||||
async def test_add_writes_one_row_and_leaves_the_others_alone(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_HOST: "10.0.0.9"}
|
||||
)
|
||||
|
||||
result = await flow.async_step_apps_add(
|
||||
{
|
||||
APP_PACKAGE: "com.b",
|
||||
APP_NAME: "Beta",
|
||||
APP_ICON: "http://icons.local/b.png",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu" # back at the editor, nothing committed yet
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta",
|
||||
}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
||||
"com.b": "http://icons.local/b.png"
|
||||
}
|
||||
assert flow._options_dict[CONF_HOST] == "10.0.0.9" # another screen's field
|
||||
|
||||
async def test_an_invalid_row_is_refused_and_stores_nothing(self, tmp_path):
|
||||
flow = self._flow(tmp_path)
|
||||
|
||||
form = await flow.async_step_apps_add(
|
||||
{APP_PACKAGE: "has spaces", APP_NAME: "Nope", APP_ICON: ""}
|
||||
)
|
||||
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps_add"
|
||||
assert form["errors"] == {"base": "invalid_app_row"}
|
||||
assert CONF_APP_NAME_MAP not in flow._options_dict
|
||||
|
||||
async def test_edit_prefills_the_stored_row_and_changes_only_it(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path,
|
||||
{
|
||||
CONF_APP_NAME_MAP: {"com.a": "Alpha", "com.b": "Beta"},
|
||||
CONF_APP_ICON_MAP: {"com.b": "http://icons.local/b.png"},
|
||||
},
|
||||
)
|
||||
|
||||
choose = await flow.async_step_apps_edit(None)
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
|
||||
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.b"})
|
||||
suggested_values = form["data_schema"]["__suggested__"]
|
||||
assert suggested_values[APP_NAME] == "Beta"
|
||||
assert suggested_values[APP_ICON] == "http://icons.local/b.png"
|
||||
|
||||
result = await flow.async_step_apps_edit_form(
|
||||
{APP_NAME: "Beta (living room)", APP_ICON: "http://icons.local/b2.png"}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta (living room)",
|
||||
}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
||||
"com.b": "http://icons.local/b2.png"
|
||||
}
|
||||
|
||||
async def test_remove_needs_the_confirmation_row(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_APP_ICON_MAP: {}}
|
||||
)
|
||||
|
||||
choose = await flow.async_step_apps_remove(None)
|
||||
assert choose["step_id"] == "apps_remove"
|
||||
|
||||
confirm = await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
||||
assert confirm["type"] == "menu"
|
||||
assert confirm["step_id"] == "apps_remove_confirm"
|
||||
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
|
||||
|
||||
kept = await flow.async_step_apps_remove_no(None)
|
||||
assert kept["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {"com.a": "Alpha"}
|
||||
|
||||
await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
||||
dropped = await flow.async_step_apps_remove_yes(None)
|
||||
|
||||
assert dropped["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {}
|
||||
|
||||
|
||||
class TestDiscoverSaysWhatHappened:
|
||||
"""t64: "Discover ... seems to load then do nothing" must be impossible."""
|
||||
|
||||
class _Manager:
|
||||
def __init__(self, rows=None, connected=True, fail=False):
|
||||
self.rows = list(rows or [])
|
||||
self.connected = connected
|
||||
self.fail = fail
|
||||
self.refresh_calls: list[bool] = []
|
||||
|
||||
def installed_apps(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
async def async_refresh_installed_apps(self, force=False):
|
||||
self.refresh_calls.append(force)
|
||||
if self.fail:
|
||||
raise RuntimeError("adb went away")
|
||||
return self.installed_apps()
|
||||
|
||||
def _flow_with(self, tmp_path, manager):
|
||||
flow = _options_flow(tmp_path, {})
|
||||
flow.config_entry.runtime_data = manager
|
||||
return flow
|
||||
|
||||
async def test_no_manager_says_adb_is_not_set_up(self, tmp_path):
|
||||
flow = _options_flow(tmp_path, {})
|
||||
|
||||
await flow.async_step_discover(None)
|
||||
|
||||
assert flow._menu_placeholders()["discovery"] == (
|
||||
"ADB is not set up for this entry — nothing could be discovered."
|
||||
)
|
||||
|
||||
async def test_finding_apps_reports_the_count_and_updates_the_table(
|
||||
self, tmp_path
|
||||
):
|
||||
manager = self._Manager(
|
||||
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}]
|
||||
)
|
||||
flow = self._flow_with(tmp_path, manager)
|
||||
|
||||
result = await flow.async_step_discover(None)
|
||||
|
||||
assert result["type"] == "menu"
|
||||
assert manager.refresh_calls == [True]
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert note.startswith("1 apps found on the Shield") # the t57 wording
|
||||
assert "updated" in note
|
||||
form = await _options_open(flow, "apps_text")
|
||||
assert "org.xbmc.kodi=Kodi" in form["data_schema"]["__suggested__"][
|
||||
CONF_APP_NAME_MAP
|
||||
]
|
||||
|
||||
async def test_finding_nothing_says_the_saved_rows_stay(self, tmp_path):
|
||||
flow = self._flow_with(tmp_path, self._Manager([]))
|
||||
|
||||
await flow.async_step_discover(None)
|
||||
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert "no installed apps" in note and "keeps your" in note
|
||||
|
||||
async def test_a_failed_sweep_says_so_instead_of_nothing(self, tmp_path):
|
||||
flow = self._flow_with(tmp_path, self._Manager([], connected=False, fail=True))
|
||||
|
||||
result = await flow.async_step_discover(None)
|
||||
|
||||
assert result["type"] == "menu" # no traceback, no dead end
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert "could not reach" in note and "unchanged" in note
|
||||
|
||||
|
||||
class TestReceiverAcceptsASwitch:
|
||||
"""t56: a soundbar that only knows on/off is a valid receiver."""
|
||||
|
||||
|
||||
@@ -524,7 +524,9 @@ _OPTIONS_SUBSTEP_FIELDS = {
|
||||
"power_entity",
|
||||
},
|
||||
"receiver": {"receiver_entity", "receiver_input"},
|
||||
"apps": {"app_name_map", "app_icon_map"},
|
||||
# t64: the apps table lives in its OWN step now — the Apps sub-screen is a
|
||||
# menu of row actions, so the field that owns the map is `apps_text`.
|
||||
"apps_text": {"app_name_map", "app_icon_map"},
|
||||
"scripts": {
|
||||
"remote_command_script",
|
||||
"channel_digit_delay",
|
||||
@@ -534,6 +536,16 @@ _OPTIONS_SUBSTEP_FIELDS = {
|
||||
},
|
||||
}
|
||||
|
||||
#: t64: the Apps editor's rows live one level deeper (the Apps sub-screen is a
|
||||
#: MENU of row actions), exactly as the browser navigates them.
|
||||
_OPTIONS_NESTED_STEPS = {
|
||||
"apps_text": "apps",
|
||||
"apps_add": "apps",
|
||||
"apps_edit": "apps",
|
||||
"apps_edit_form": "apps",
|
||||
"apps_remove": "apps",
|
||||
"apps_remove_confirm": "apps",
|
||||
}
|
||||
|
||||
def _options_step_of(key: str) -> str:
|
||||
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
|
||||
@@ -556,9 +568,16 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
grouped.setdefault(_options_step_of(key), {})[key] = value
|
||||
result = await flow.async_step_init(None)
|
||||
for step, sub in grouped.items():
|
||||
parent = _OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{step}")(sub)
|
||||
if save:
|
||||
# t64: a nested row returns to the APPS menu, so walk back to the main menu
|
||||
# the way the "Back to the main menu" row does before committing.
|
||||
result = await flow.async_step_menu_again()
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await flow.async_step_finish(None)
|
||||
return result
|
||||
@@ -567,6 +586,10 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
async def _options_open(flow, step: str):
|
||||
"""Choose an options sub-screen from the menu and open its form."""
|
||||
result = await flow.async_step_init(None)
|
||||
parent = _OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
return await getattr(flow, f"async_step_{step}")(None)
|
||||
|
||||
@@ -1309,7 +1332,7 @@ class TestAppsMapOptionsEditor:
|
||||
|
||||
# first visit: the form carries the serialized current map as the
|
||||
# field default — NEVER the dict (that is the [object Object] bug)
|
||||
result = await _options_open(flow, "apps")
|
||||
result = await _options_open(flow, "apps_text")
|
||||
|
||||
assert result["type"] == "form"
|
||||
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
||||
|
||||
Reference in new issue
Block a user