fix(config_flow): F1 — a discoverable later pairing path (Reconfigure) and truthful skip copy (t35 F1/F1b)
"Continue without protocol pairing" switched the transport off for good: the options flow carries no protocol fields, so nothing in the UI could ever turn it back on, and the skip copy promised nothing about it (t34 F1). The entry now answers Home Assistant's Reconfigure action: * async_step_reconfigure (audit G5) seeds the flow from the entry (title, adb host/port/enabled, androidtv entity for the host fallback) and re-runs the pairing step — the address is editable, the Shield shows a PIN, and _finish_reconfigure persists protocol_enabled=True plus the address that just paired (so the transport no longer depends on the ADB fallback), adopts the paired MAC when it differs, and updates + reloads the entry. HA derives the "reconfigure_successful" abort from the flow source. A Reconfigure visit that clicks skip aborts honestly with "reconfigure_skipped" (entry untouched), and an entry that is gone aborts with "reconfigure_entry_missing". * the skip data_description now states the truth — the transport stays off until you pair it — and names the later path (the integration's entry menu > Reconfigure). * a host corrected on the pairing form now DROPS the session started against the old address and restarts pairing there, so the typed address is authoritative instead of being paired against the stale one. * the success path no longer runs _abort_if_unique_id_configured for a flow that already owns an entry: core looks the domain up by unique_id, so re-pairing an entry aborted on itself as "already_configured" (this also unblocked reauth). The fresh-setup path is unchanged. * adjacent fix while wiring the same call: _finish_reauth passed `options_updates`, which core's async_update_reload_and_abort does not accept (the kwarg is `options`, REPLACE) — the reauth path would have raised TypeError in production. Both paths now pass `options` and the test stubs mirror the real signature. Tests: tests/test_config_flow_ux.py::TestProtocolReconfigure drives reconfigure from a protocol-disabled entry through to updated options (transport on, host persisted, other options kept, MAC adopted, reload recorded), a corrected host reopening the PIN step against the new address, the skip abort, and the missing-entry abort; TestCopyContract gained the F1b copy pin. Against HEAD's config_flow.py all four reconfigure tests fail (no async_step_reconfigure). Verified: uvx --with pytest-asyncio pytest tests -q → 281 passed, 1 skipped; uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 282 passed; uvx ruff check custom_components tests → clean.
This commit is contained in:
1 parent
7a0152e6b5
commit
e11dccf2c8
6 files changed
+255
-10
No files matched your search
@@ -231,6 +231,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._user_data = {}
|
||||
self._protocol_client = None
|
||||
self._reauth_entry = None
|
||||
# audit G5 / t35 F1: the entry a "Reconfigure" visit targets
|
||||
self._reconfigure_entry = None
|
||||
# Zeroconf discovery state (P4-1)
|
||||
self._discovered_host: str | None = None
|
||||
self._discovered_name: str | None = None
|
||||
@@ -542,6 +544,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# say so instead of pretending the re-pair worked
|
||||
self._reauth_entry = None
|
||||
return self.async_abort(reason="reauth_skipped")
|
||||
if self._reconfigure_entry is not None:
|
||||
# same for a Reconfigure visit: nothing changed, the entry keeps
|
||||
# its options and can be reconfigured again at any time
|
||||
self._reconfigure_entry = None
|
||||
return self.async_abort(reason="reconfigure_skipped")
|
||||
return self._create_entry()
|
||||
|
||||
async def _async_restart_protocol_pairing(self, errors: dict):
|
||||
@@ -578,9 +585,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
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.
|
||||
# A host typed HERE is authoritative — the user step is gone, so
|
||||
# the session started against the old address is dropped and the
|
||||
# Shield is asked for a NEW code at the corrected address (t35 F1).
|
||||
self._user_data[CONF_PROTOCOL_HOST] = typed_host
|
||||
self._discard_protocol_client()
|
||||
|
||||
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
|
||||
if self._protocol_client is None or not code:
|
||||
@@ -602,11 +611,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
with contextlib.suppress(Exception):
|
||||
client.disconnect()
|
||||
self._protocol_client = None
|
||||
if mac:
|
||||
target = self._reauth_entry or self._reconfigure_entry
|
||||
if mac and mac != getattr(target, "unique_id", None):
|
||||
# A MAC that is already configured must never be adopted silently.
|
||||
# A reauth/reconfigure flow already OWNS an entry, and
|
||||
# _abort_if_unique_id_configured looks the domain up by unique_id —
|
||||
# it would abort on that very entry when the MAC is unchanged
|
||||
# (t35 F1: re-pairing an entry used to abort as "already_configured").
|
||||
await self.async_set_unique_id(mac)
|
||||
self._abort_if_unique_id_configured()
|
||||
if self._reauth_entry is not None:
|
||||
return self._finish_reauth()
|
||||
if self._reconfigure_entry is not None:
|
||||
return self._finish_reconfigure(mac)
|
||||
return self._create_entry()
|
||||
|
||||
async def async_step_import(self, data):
|
||||
@@ -687,17 +704,68 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="reauth_confirm", data_schema=vol.Schema({})
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(self, user_input=None):
|
||||
"""Re-run protocol pairing for an existing entry (audit G5, t35 F1).
|
||||
|
||||
Home Assistant offers this as the entry's "Reconfigure" action — the
|
||||
discoverable way back for an entry whose protocol transport is off
|
||||
(typically the "continue without protocol pairing" outcome): the
|
||||
address is editable, the Shield shows a PIN, and the entry's options
|
||||
are updated + reloaded.
|
||||
"""
|
||||
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||
if entry is None:
|
||||
# HA always links the entry; a missing one gets a plain abort
|
||||
# instead of an exception
|
||||
return self.async_abort(reason="reconfigure_entry_missing")
|
||||
self._reconfigure_entry = entry
|
||||
# Seed the flow from the entry so the pairing step offers the address
|
||||
# the entry already knows (and _create_entry is never reached here).
|
||||
self._user_data = {
|
||||
"name": entry.title,
|
||||
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
|
||||
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||||
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
||||
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||||
}
|
||||
return await self.async_step_protocol_pair()
|
||||
|
||||
def _finish_reconfigure(self, mac: str | None):
|
||||
"""Reconfigure pairing done: update + reload + abort (G3a contract).
|
||||
|
||||
The protocol host persists as the address that just paired, so the
|
||||
entry no longer depends on the ADB-host fallback for its transport.
|
||||
HA derives the abort reason ("reconfigure_successful") from the flow
|
||||
source, so this needs no integration string.
|
||||
"""
|
||||
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||
updates: dict = {
|
||||
"options": {
|
||||
**entry.options,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: self._protocol_host() or "",
|
||||
},
|
||||
}
|
||||
if mac:
|
||||
# a swapped device keeps the entry's identity honest
|
||||
updates["unique_id"] = mac
|
||||
self._reconfigure_entry = None
|
||||
return self.async_update_reload_and_abort(entry, **updates)
|
||||
|
||||
def _finish_reauth(self):
|
||||
"""Reauth pairing done: update + reload + abort via the current API (G3a).
|
||||
|
||||
The deprecated update-entry + schedule-reload boomerang listener is
|
||||
gone (2026.12 treats it as an error); the current config-entries
|
||||
contract is async_update_reload_and_abort with _get_reauth_entry.
|
||||
Core's kwarg is `options` (REPLACE) — there is no `options_updates`.
|
||||
"""
|
||||
entry = self._reauth_entry or self._get_reauth_entry()
|
||||
self.async_update_reload_and_abort(
|
||||
entry,
|
||||
options_updates={
|
||||
options={
|
||||
**entry.options,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup now without the remote-protocol transport. Power, availability and the app push then come from the other state sources only."
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -80,6 +80,8 @@
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||
"yaml_seed_only": {},
|
||||
"yaml_seed": {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup now without the remote-protocol transport. Power, availability and the app push then come from the other state sources only."
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -80,6 +80,8 @@
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||
"yaml_seed_only": {},
|
||||
"yaml_seed": {
|
||||
|
||||
Reference in new issue
Block a user