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

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

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

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

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

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

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

No files matched your search

+25 -2
View File
@@ -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"])