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
+156
-28
No files matched your search
@@ -32,6 +32,7 @@ from .const import (
|
|||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
CONF_RECEIVER_INPUT,
|
CONF_RECEIVER_INPUT,
|
||||||
|
CONF_SKIP_PAIRING,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -463,11 +464,21 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""Protocol host: explicit override, else the ADB host fallback."""
|
"""Protocol host: explicit override, else the ADB host fallback."""
|
||||||
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
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):
|
async def _start_protocol_pairing(self):
|
||||||
"""Build a throwaway protocol client and make the Shield show its PIN.
|
"""Build a throwaway protocol client and make the Shield show its PIN.
|
||||||
|
|
||||||
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
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()
|
host = self._protocol_host()
|
||||||
if not host:
|
if not host:
|
||||||
@@ -481,32 +492,109 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
certfile=certfile,
|
certfile=certfile,
|
||||||
keyfile=keyfile,
|
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_generate_cert_if_missing()
|
||||||
await client.async_start_pairing()
|
await client.async_start_pairing()
|
||||||
return client
|
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):
|
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 = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
# First visit: proactively start pairing so the PIN shows on the
|
# First visit: proactively start pairing so the PIN shows on the
|
||||||
# Shield while the form is open.
|
# Shield while the form is open.
|
||||||
client = await self._start_protocol_pairing()
|
return await self._async_restart_protocol_pairing(errors)
|
||||||
if client is None:
|
|
||||||
errors["base"] = "protocol_no_host"
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
else:
|
return await self._async_skip_protocol_pairing()
|
||||||
self._protocol_client = client
|
|
||||||
else:
|
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
|
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:
|
try:
|
||||||
await client.async_finish_pairing(code)
|
await client.async_finish_pairing(code)
|
||||||
except Exception:
|
except Exception:
|
||||||
errors["base"] = "protocol_pair_failed"
|
errors["base"] = "protocol_pair_failed"
|
||||||
else:
|
return self._show_protocol_pair_form(errors)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_name, mac = await client.async_get_name_and_mac()
|
_name, mac = await client.async_get_name_and_mac()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -520,13 +608,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if self._reauth_entry is not None:
|
if self._reauth_entry is not None:
|
||||||
return self._finish_reauth()
|
return self._finish_reauth()
|
||||||
return self._create_entry()
|
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_import(self, data):
|
async def async_step_import(self, data):
|
||||||
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
"""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)
|
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):
|
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 = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host = self._resolve_adb_host()
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
# 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:
|
if not host:
|
||||||
errors["base"] = "adb_no_host"
|
errors["base"] = "adb_no_host"
|
||||||
else:
|
else:
|
||||||
@@ -654,11 +782,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
errors["base"] = "adb_failed"
|
errors["base"] = "adb_failed"
|
||||||
|
|
||||||
return self.async_show_form(
|
return self._show_adb_pair_form(errors)
|
||||||
step_id="adb_pair",
|
|
||||||
data_schema=vol.Schema({}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ CONF_ADB_PORT = "adb_port"
|
|||||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||||
CONF_PROTOCOL_HOST = "protocol_host"
|
CONF_PROTOCOL_HOST = "protocol_host"
|
||||||
CONF_PAIRING_CODE = "pairing_code"
|
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_API_PORT = 6466
|
||||||
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user