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_