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:
1 parent
73e886dafe
commit
a6cc56feb4
2 files changed
+174
-46
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user