fix(config_flow): F-B — neither pairing step is a dead end (t33)

protocol_pair and adb_pair now each offer "continue without pairing" (the new
CONF_SKIP_PAIRING form key), so the entry still finishes — in passive mode —
instead of stranding the user on a form with no way forward.

adb_pair: the ADB host/port live ON this form (the user step is gone), a host
typed here is authoritative, the addr the error names is therefore fixable
where the error appears, and skip routes through _async_after_pairing. ADB
stays enabled on purpose: the manager already degrades to its passive,
background-retrying state without a key, and the Pair ADB button (registered
for adb-enabled entries) then pairs later without re-adding the integration.

protocol_pair: the protocol host lives on this form too and a corrected
address restarts the pairing session against it; submitting without a code asks
the Shield for a fresh code (_async_restart_protocol_pairing) instead of
erroring; skip switches the transport off, because an unpaired protocol client
fails auth on its first connect and would only bounce the user into the reauth
flow — a fresh dead end. On reauth the skip aborts with reauth_skipped instead
of faking a repair.

_start_protocol_pairing also returns None now when build_protocol_client does
(androidtvremote2 missing) so the step reports protocol_unavailable instead of
crashing on a None client in the default CI variant.

Verified: uvx --with pytest-asyncio pytest tests -q → 242 passed, 1 skipped;
uvx ruff check custom_components tests → clean. The end-to-end skip-path and
copy tests land with the t33 UX suite commit.
This commit is contained in:
James Mackay committed 2026-10-10 20:50:22 +01:00
1 parent 73e886dafe
commit a6cc56feb4
2 files changed
+174 -46

No files matched your search

+170 -46
View File
@@ -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/<domain>/`` (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