diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 328246e..fdaec45 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -32,6 +32,7 @@ from .const import ( CONF_PROTOCOL_HOST, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, + CONF_SKIP_PAIRING, DEFAULT_ADB_PORT, DEFAULT_NAME, DOMAIN, @@ -463,11 +464,21 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Protocol host: explicit override, else the ADB host fallback.""" return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host() + def _discard_protocol_client(self) -> None: + """Drop a pairing client whose session became void (host corrected).""" + client = self._protocol_client + self._protocol_client = None + if client is not None: + with contextlib.suppress(Exception): + client.disconnect() + async def _start_protocol_pairing(self): """Build a throwaway protocol client and make the Shield show its PIN. Certificates live under ``.storage//`` (core androidtv_remote - helpers.py pattern) and are shared between pairing and runtime. + helpers.py pattern) and are shared between pairing and runtime. Returns + None when no host is resolvable or ``androidtvremote2`` is missing — + the caller turns that into an error key, never a crash. """ host = self._protocol_host() if not host: @@ -481,52 +492,122 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): certfile=certfile, keyfile=keyfile, ) + if client is None: + # build_protocol_client already logged the missing library; + # pairing cannot start, and the form still offers the skip path + return None await client.async_generate_cert_if_missing() await client.async_start_pairing() return client + async def _async_begin_protocol_pairing(self): + """Start a PIN pairing session: ``(client, None)`` or ``(None, error)``.""" + if not self._protocol_host(): + return None, "protocol_no_host" + client = await self._start_protocol_pairing() + if client is None: + return None, "protocol_unavailable" + return client, None + + def _show_protocol_pair_form(self, errors=None): + """protocol_pair form: the PIN, the address, and the skip escape. + + t33 F-B/F-C: the host field lives on THIS form, so an unresolvable + address is fixable where the error is shown (the user step is gone), + and the skip option keeps the step from being a dead end. + """ + return self.async_show_form( + step_id="protocol_pair", + data_schema=vol.Schema( + { + vol.Required(CONF_PAIRING_CODE): str, + _host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str, + vol.Optional(CONF_SKIP_PAIRING, default=False): bool, + } + ), + errors=errors or {}, + ) + + async def _async_skip_protocol_pairing(self): + """Continue without the protocol transport (t33 F-B, passive mode). + + The transport is switched OFF rather than left half-paired: an + unpaired protocol client fails auth on its first connect and would + only bounce the user into the reauth flow — a fresh dead end. + """ + self._discard_protocol_client() + self._user_data[CONF_PROTOCOL_ENABLED] = False + if self._reauth_entry is not None: + # an existing entry cannot be re-created: leave it untouched and + # say so instead of pretending the re-pair worked + self._reauth_entry = None + return self.async_abort(reason="reauth_skipped") + return self._create_entry() + + async def _async_restart_protocol_pairing(self, errors: dict): + """Start a fresh pairing session and render the form for its code. + + Called on the first visit, after a host correction, and when the user + submits without a code: each is a request for the Shield to show a new + code, and none of them may leave the step stuck. + """ + self._discard_protocol_client() + client, error = await self._async_begin_protocol_pairing() + if error: + errors["base"] = error + else: + self._protocol_client = client + return self._show_protocol_pair_form(errors) + async def async_step_protocol_pair(self, user_input=None): - """Pair with the remote protocol — the PIN is displayed on the Shield.""" + """Pair with the remote protocol — the PIN is displayed on the Shield. + + Never a dead end (t33 F-B): a corrected address restarts pairing right + here, submitting without a code asks for a fresh one, and + ``skip_pairing`` finishes the entry in passive mode. + """ errors = {} + if user_input is None: # First visit: proactively start pairing so the PIN shows on the # Shield while the form is open. - client = await self._start_protocol_pairing() - if client is None: - errors["base"] = "protocol_no_host" - else: - self._protocol_client = client - else: - client = self._protocol_client - code = user_input.get(CONF_PAIRING_CODE, "") - if client is None or not code: - errors["base"] = "protocol_invalid_code" - else: - try: - await client.async_finish_pairing(code) - except Exception: - errors["base"] = "protocol_pair_failed" - else: - try: - _name, mac = await client.async_get_name_and_mac() - except Exception: - mac = None - with contextlib.suppress(Exception): - client.disconnect() - self._protocol_client = None - if mac: - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() - if self._reauth_entry is not None: - return self._finish_reauth() - return self._create_entry() - return self.async_show_form( - step_id="protocol_pair", - data_schema=vol.Schema({ - vol.Required(CONF_PAIRING_CODE): str, - }), - errors=errors, - ) + return await self._async_restart_protocol_pairing(errors) + + if user_input.get(CONF_SKIP_PAIRING): + return await self._async_skip_protocol_pairing() + + typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip() + if typed_host and typed_host != (self._protocol_host() or ""): + # A host typed HERE is authoritative — the user step is gone, and + # the session started against the old address is void. + self._user_data[CONF_PROTOCOL_HOST] = typed_host + + code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip() + if self._protocol_client is None or not code: + # No live session, or no code typed: (re)start pairing so the + # Shield shows a code for the address currently on the form. + return await self._async_restart_protocol_pairing(errors) + + client = self._protocol_client + try: + await client.async_finish_pairing(code) + except Exception: + errors["base"] = "protocol_pair_failed" + return self._show_protocol_pair_form(errors) + + try: + _name, mac = await client.async_get_name_and_mac() + except Exception: + mac = None + with contextlib.suppress(Exception): + client.disconnect() + self._protocol_client = None + if mac: + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + if self._reauth_entry is not None: + return self._finish_reauth() + return self._create_entry() async def async_step_import(self, data): """Seed an interactive flow from YAML (t30 U1, seed-only ruling). @@ -631,14 +712,61 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY) ) + def _show_adb_pair_form(self, errors=None): + """adb_pair form: the address/port (fixable here) and the skip escape. + + t33 F-B/F-C/F-D: the fields live on THIS form — the user step is gone + — so a missing address is fixable where the error appears, the + standard 5555 is pre-filled, and the skip option ends the dead end. + """ + return self.async_show_form( + step_id="adb_pair", + data_schema=vol.Schema( + { + _host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str, + vol.Optional( + CONF_ADB_PORT, + default=self._user_data.get( + CONF_ADB_PORT, DEFAULT_ADB_PORT + ), + ): int, + vol.Optional(CONF_SKIP_PAIRING, default=False): bool, + } + ), + errors=errors or {}, + ) + async def async_step_adb_pair(self, user_input=None): - """Step 2: ADB pairing. User clicks Submit after approving on the Shield.""" + """Step 2: ADB pairing. Approve the prompt on the Shield, then Submit. + + Never a dead end (t33 F-B): the host/port are editable on the form, a + missing address is reported against the field above it, and the skip + option finishes the entry in passive mode. + """ errors = {} if user_input is not None: - host = self._resolve_adb_host() - port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) + if user_input.get(CONF_SKIP_PAIRING): + # Continue WITHOUT pairing. ADB stays enabled on purpose: the + # manager already degrades to its passive, background-retrying + # state when no key exists, and the Pair ADB button (only + # registered for adb-enabled entries) pairs later without + # re-adding the integration. + return await self._async_after_pairing() + typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip() + if typed_host: + # a host typed HERE is authoritative (the user step is gone) + self._user_data[CONF_ADB_HOST] = typed_host + + port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) + try: + port = int(port) + except (TypeError, ValueError): + port = DEFAULT_ADB_PORT + self._user_data[CONF_ADB_PORT] = port + + host = self._resolve_adb_host() if not host: errors["base"] = "adb_no_host" else: @@ -654,11 +782,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: errors["base"] = "adb_failed" - return self.async_show_form( - step_id="adb_pair", - data_schema=vol.Schema({}), - errors=errors, - ) + return self._show_adb_pair_form(errors) @staticmethod @callback diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index 99a5e77..a7159a4 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -26,6 +26,10 @@ CONF_ADB_PORT = "adb_port" CONF_PROTOCOL_ENABLED = "protocol_enabled" CONF_PROTOCOL_HOST = "protocol_host" CONF_PAIRING_CODE = "pairing_code" +# t33 F-B: the "continue without pairing" escape offered on BOTH pairing +# steps — the entry still finishes (passive mode) instead of stranding the +# user on a form with no way forward. +CONF_SKIP_PAIRING = "skip_pairing" DEFAULT_PROTOCOL_API_PORT = 6466 DEFAULT_PROTOCOL_PAIR_PORT = 6467