From cd8f528a27a74fa34dce0d1e47cadd570f38f3c1 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Mon, 12 Oct 2026 07:17:21 +0100 Subject: [PATCH] feat(config-flow): menu of sub-screens on setup and options Both configure pages were one long form: a field you wanted to change meant re-submitting everything, and the options page rendered the apps map as `[object Object]`. Verified against a real Home Assistant install (not assumed): the HTTP flow view serialises a field's suggested value verbatim, so a dict handed to a `type: string` field reaches the browser as a non-string; and real HA validates a submitted page against the schema the step returned, where the built-in EntitySelector rejects `''` and `None`. - The options flow is a MENU (Main setup / Receiver / Apps / Scripts / Save and close). Each sub-screen owns a disjoint set of keys and merges into one pending dict under a single rule: a key the submission CARRIES is authoritative (empty clears), a key it does not carry is untouched. The per-command script dict is rebuilt from the submitted script fields, so unsetting one persists instead of surviving as a stale key. - The sub-screen step_id IS the menu row id. Real HA dispatches a menu choice and a submitted form by that same name, so a mismatch is an UnknownStep or an unsubmittable page (the setup menu first tried a "setup_menu" step id and dead-ended in real HA). - The apps map reaches the suggestion layer serialized to the documented `package=name[|icon]` lines, and parses back to both maps. - The setup flow keeps its first form and gains a menu after it; pairing completion returns to the menu, and the "ADB then protocol" sequence still happens automatically. - Receiver accepts a `switch.*` as well as a media_player. Tests: 522 pass on stubs, and tests/test_config_flow_real_ha.py drives the real FlowManager + probatio + to_field_list; 53 + 11 of those fail on the pre-fix tree. --- .../nvidia_shield/config_flow.py | 672 +++++++++++++----- custom_components/nvidia_shield/strings.json | 171 ++++- .../nvidia_shield/translations/en.json | 171 ++++- 3 files changed, 808 insertions(+), 206 deletions(-) diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 6c7508d..a47640d 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -130,6 +130,49 @@ def _app_map_from_text( return app_map +def _options_field_values(options: dict | None) -> dict: + """Entry options as FRONTEND-SERIALIZABLE field values (t56). + + The bug this exists to kill: the apps map is stored as a DICT, and the + options page handed that dict straight to ``add_suggested_values_to_schema`` + as the suggested value of a TEXT field. Real Home Assistant serializes the + suggested value verbatim into the form the frontend receives (verified on a + real install: ``{"suggested_value": {"ar.tvplayer.tv": "TiviMate"}}`` for a + ``type: string`` field), and the browser renders a non-string as + ``[object Object]``. + + So the suggestion layer gets only what a text field can display: the apps + map (plus its icon column) serialized to the documented + ``package=name[|icon]`` lines, and every other option value unchanged. + """ + options = dict(options or {}) + app_map = options.pop(CONF_APP_NAME_MAP, None) + icon_map = options.pop(CONF_APP_ICON_MAP, None) + if isinstance(app_map, dict): + options[CONF_APP_NAME_MAP] = _app_map_to_text( + app_map, icon_map if isinstance(icon_map, dict) else None + ) + return options + + +def _app_map_submission(value) -> tuple[dict, dict] | None: + """Parse the apps-table field into ``(app_map, icon_map)``; None if invalid. + + A blank field is the documented way to clear the map; anything else must + parse as ``package=name[|icon]`` lines or the whole submission is rejected + (never half-stored). + """ + if value is None: + return {}, {} + if not str(value).strip(): + return {}, {} + icons: dict[str, str] = {} + app_map = _app_map_from_text(str(value), icons) + if app_map is None: + return None + return app_map, icons + + def _host_field_key(field: str, host: str): """The host-field KEY, carrying the known address as its default. @@ -208,8 +251,13 @@ def _build_selectors(): "media_player": _ClearingEntitySelector( selector.EntitySelectorConfig(domain="media_player") ), + # t56: a receiver is a media_player (an AVR/soundbar) OR a plain switch + # (a soundbar/speaker that only knows on and off). The device_class + # constraint belongs to the media_player half only, so a switch is not + # filtered out by it — and the switch behaviour itself (turning it on + # during playback) lives in the media_player task that follows. "receiver": _ClearingEntitySelector( - selector.EntitySelectorConfig(domain="media_player", device_class="receiver") + selector.EntitySelectorConfig(domain=["media_player", "switch"]) ), "power": _ClearingEntitySelector( selector.EntitySelectorConfig(domain=["switch", "binary_sensor"]) @@ -289,14 +337,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True), } - # If ADB is enabled, go to pairing step - if self._user_data[CONF_ADB_ENABLED]: - return await self.async_step_adb_pair() - - if self._user_data[CONF_PROTOCOL_ENABLED]: - return await self.async_step_protocol_pair() - - return self._create_entry() + # t56: the first form stays exactly one screen (so nothing it used + # to accept moves or disappears), and the flow then becomes a MENU + # of sub-screens — Connection / Receiver / the pairing steps / + # Finish — instead of a single long page or a hidden sequence. + return await self.async_step_menu() return self._show_user_form(errors) @@ -404,15 +449,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() def _create_entry(self): - """Create the config entry from stored user data. + """Create the config entry from stored user data + setup sub-screens. Everything the integration needs lives in the OPTIONS (t41: one host field among them); ``data`` stays empty because no value has to be - immutable. + immutable. The setup sub-screens (t56) contribute ``_setup_extras`` — + disjoint keys merged over the first form, so a value configured on a + sub-screen wins over the same key left blank on the first form. """ - d = self._user_data + d = {**self._user_data, **self._setup_extras} return self.async_create_entry( - title=d["name"], + # the name is the entry title; a flow driven programmatically (and + # the first form always carries it) may omit it, so fall back + title=d.get("name") or DEFAULT_NAME, data={}, options={ CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""), @@ -428,11 +477,18 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) async def _async_after_pairing(self): - """Route after the pairing steps: protocol next, else create the entry.""" - if self._user_data.get(CONF_PROTOCOL_ENABLED): - return await self.async_step_protocol_pair() - return self._create_entry() + """Route after a pairing step: the protocol next, else the setup menu (t56). + The "ADB paired, now the protocol" sequence is deliberate and stays + automatic — a user who asked for both transports should not have to find + the second one in a menu — and the setup menu afterwards is where the + remaining sub-screens (Connection / Receiver) and Finish live. + """ + if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr( + self, "_paired_protocol", False + ): + return await self.async_step_protocol_pair() + return self._show_setup_menu() def _discard_protocol_client(self) -> None: """Drop a pairing client whose session became void (host corrected).""" @@ -607,7 +663,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self._finish_reauth() if self._reconfigure_entry is not None: return self._finish_reconfigure(mac) - return self._create_entry() + # t56: the protocol is paired now, so the setup menu must not offer to + # pair it again — it returns to the menu (Receiver / Connection / + # Finish) instead of creating the entry here. + self._paired_protocol = True + return self._show_setup_menu() async def async_step_import(self, data): """Seed an interactive flow from YAML (t30 U1, seed-only ruling). @@ -765,6 +825,157 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): or "" ).strip() + # ── t56: the setup menu and its sub-screens ── + + @property + def _setup_extras(self) -> dict: + """Options collected on the setup sub-screens (t56), merged, not last-writer-wins. + + Lazily created so the flow's ``__init__`` stays untouched; the menu's + sub-screens each own a disjoint set of keys and merge into this dict, so + configuring the receiver and then the connection (in either order) + cannot drop the other's edits. + """ + if getattr(self, "_setup_extra_options", None) is None: + self._setup_extra_options = {} + return self._setup_extra_options + + def _show_setup_menu(self, errors=None): + """The setup menu: connection, receiver, and finishing the entry (t56). + + Home Assistant's standard menu step: it renders every option as a row + and dispatches the choice to ``async_step_