fix(config): degrade an unreachable Shield to the pairing form (t44 F1)

`_start_protocol_pairing` called androidtvremote2's cert generation and
`async_start_pairing` with no try/except, so `CannotConnect` propagated straight
out of `async_step_protocol_pair`. The caller already had the shape for a soft
failure — `_async_begin_protocol_pairing()` returns `(client, None) | (None,
error)` and `_async_restart_protocol_pairing` renders `errors["base"]` — the
exception simply bypassed it, and the protocol_pair form never appeared: not the
address field, not the "continue without pairing" escape.

That made it a default-path crash rather than an edge case: t41 turned the
protocol transport ON by default, so an asleep, rebooting or mis-addressed Shield
(whenever someone adds the integration, or skips ADB pairing into this step)
raised out of the flow instead of showing the form. The reviewer reproduced both
under real HA.

Now the step returns `(None, "protocol_unreachable")`, logs the address and the
library error, discards the half-built client so a retry starts clean, and renders
the same form the other failures render — with the address editable and the skip
option intact. `protocol_unreachable` is documented in strings.json AND
translations/en.json (a form error without copy shows a bare key in the UI).

Tests (tests/test_config_flow_ux.py::TestUnreachableShieldDuringPairing): the fake
client can now fail to start pairing, and five cases cover fresh setup, the
ADB-skip route into the protocol step, reconfigure, reauth and the skip path that
still finishes the entry — all as FORMS, never exceptions. Red proof on HEAD:
the same drive raises CannotConnectStubError out of async_step_user.

Verified: 361 passed / 2 skipped, 362 / 1 with androidtvremote2, ruff clean.
This commit is contained in:
James Mackay committed 2026-10-11 06:37:18 +01:00
1 parent 7608518d97
commit 3175582419
5 files changed
+240 -16

No files matched your search

+158 -1
View File
@@ -282,21 +282,32 @@ class FakeHass:
return self.async_create_task(coro)
class CannotConnectStubError(ConnectionError):
"""androidtvremote2``CannotConnect``: the Shield never answered (t44 F1)."""
class FakeProtocolClient:
"""Deterministic androidtvremote2 stand-in for the PIN pairing path."""
def __init__(self, fail_code: bool = False):
def __init__(self, fail_code: bool = False, start_raises: bool = False):
self.cert_generated = False
self.pairing_started = False
self.finished_codes: list[str] = []
self.disconnects = 0
self._fail_code = fail_code
# the Shield is asleep / rebooting / mis-addressed: the real library
# raises from async_start_pairing, which used to escape the config flow
self._start_raises = start_raises
async def async_generate_cert_if_missing(self):
if self._start_raises:
raise CannotConnectStubError("cert generation could not reach the Shield")
self.cert_generated = True
return True
async def async_start_pairing(self):
if self._start_raises:
raise CannotConnectStubError("no answer from the Shield")
self.pairing_started = True
return FAKE_PAIRING_CODE
@@ -1219,6 +1230,152 @@ class TestProtocolReconfigure:
assert result == {"type": "abort", "reason": "reconfigure_entry_missing"}
class TestUnreachableShieldDuringPairing:
"""t44 F1 (high): an unreachable Shield is a FORM, never an exception.
The protocol transport is ON by default, so an asleep or rebooting Shield is
the ordinary case: `_start_protocol_pairing` used to let androidtvremote2's
CannotConnect escape `async_step_protocol_pair`, and the protocol_pair form —
the address field AND the "continue without pairing" escape — never rendered.
"""
def _raising(self, monkeypatch, module):
return _patch_protocol(
monkeypatch, module, client=FakeProtocolClient(start_raises=True)
)
async def test_fresh_setup_shows_the_form_instead_of_raising(
self, tmp_path, monkeypatch
):
module = _form_module()
self._raising(monkeypatch, module)
flow, _hass = _new_flow(module, tmp_path)
form = await flow.async_step_user(
_user_input(
**{
CONF_ADB_ENABLED: False,
CONF_PROTOCOL_ENABLED: True,
CONF_HOST: DISCOVERED_HOST,
}
)
)
assert form["type"] == "form"
assert form["step_id"] == "protocol_pair"
assert form["errors"] == {"base": "protocol_unreachable"}
# both escapes are on the form: a corrected address and the skip
fields = vol_mirror.declared_fields(form["data_schema"])
assert CONF_HOST in fields
assert CONF_SKIP_PAIRING in fields
async def test_the_adb_skip_route_into_the_protocol_step_also_shows_the_form(
self, tmp_path, monkeypatch
):
"""Both transports are on by default, so ADB pairs first, then protocol."""
module = _form_module()
self._raising(monkeypatch, module)
_patch_adb(monkeypatch, module)
flow, _hass = _new_flow(module, tmp_path)
adb_form = await flow.async_step_user(
_user_input(
**{
CONF_ADB_ENABLED: True,
CONF_PROTOCOL_ENABLED: True,
CONF_HOST: DISCOVERED_HOST,
}
)
)
assert adb_form["step_id"] == "adb_pair"
result = await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
assert result["type"] == "form"
assert result["step_id"] == "protocol_pair"
assert result["errors"] == {"base": "protocol_unreachable"}
async def test_the_skip_path_still_finishes_the_entry(self, tmp_path, monkeypatch):
"""The whole point of degrading to the form: setup stays completable."""
module = _form_module()
self._raising(monkeypatch, module)
_patch_adb(monkeypatch, module)
flow, _hass = _new_flow(module, tmp_path)
await flow.async_step_user(
_user_input(
**{
CONF_ADB_ENABLED: True,
CONF_PROTOCOL_ENABLED: True,
CONF_HOST: DISCOVERED_HOST,
}
)
)
await flow.async_step_adb_pair({CONF_SKIP_PAIRING: True})
result = await flow.async_step_protocol_pair({CONF_SKIP_PAIRING: True})
assert result["type"] == "create_entry"
assert result["options"][CONF_PROTOCOL_ENABLED] is False # passive
assert result["options"][CONF_ADB_ENABLED] is True
async def test_reconfigure_survives_an_unreachable_shield(
self, tmp_path, monkeypatch
):
module = _form_module()
self._raising(monkeypatch, module)
entry = SimpleNamespace(
entry_id="e1",
title="mdarcy",
data={},
options={
CONF_PROTOCOL_ENABLED: False,
CONF_ADB_ENABLED: True,
CONF_HOST: "10.0.0.9",
CONF_ADB_PORT: 5555,
},
unique_id=None,
)
flow, hass = _new_flow(module, tmp_path)
hass.config_entries.by_id["e1"] = entry
flow.context = {"entry_id": "e1", "source": "reconfigure"}
form = await flow.async_step_reconfigure()
assert form["type"] == "form"
assert form["step_id"] == "protocol_pair"
assert form["errors"] == {"base": "protocol_unreachable"}
async def test_reauth_survives_an_unreachable_shield(self, tmp_path, monkeypatch):
module = _form_module()
self._raising(monkeypatch, module)
entry = SimpleNamespace(
entry_id="e1",
title="mdarcy",
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"},
)
flow, hass = _new_flow(module, tmp_path)
hass.config_entries.by_id["e1"] = entry
flow.context = {"entry_id": "e1"}
shown = await flow.async_step_reauth({})
assert shown["step_id"] == "reauth_confirm"
form = await flow.async_step_reauth_confirm({"confirm": True})
assert form["type"] == "form"
assert form["step_id"] == "protocol_pair"
assert form["errors"] == {"base": "protocol_unreachable"}
def test_the_new_error_key_is_documented_in_both_string_files(self):
"""A form error with no copy renders as a bare key in the UI."""
for file in (_strings(), _en_strings()):
text = file["config"]["error"]["protocol_unreachable"]
assert "did not answer" in text
assert "Continue without protocol pairing" in text
class TestCopyContract:
"""F-C: plain, self-consistent copy with no unreachable-step pointers."""