`_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.
Two defects shipped with a green suite: button.py imported a helper that had
been deleted (no harness ever loaded the file), and the drift sweep only judged
`homeassistant.*`, so a relative import of a name that no longer exists was
invisible to it. Both are now impossible to ship silently.
* tests/test_module_coverage.py loads EVERY *.py file of the package, in a clean
interpreter, under the stub harness — exhaustive by construction (a new
platform is covered the moment the file exists; there is no list and no
special case). It asserts the reported set equals the files on disk, so a
silently skipped module fails too;
* entity_stublib.load_entity_modules() now also loads button.py (the named gap);
the sweep is what keeps that list from needing to be complete;
* ha_api_drift gained pass 5: the package's own relative imports. Pure AST,
no homeassistant required, with its own entry point
`scan_package_imports()` for the fast suite — it checks the target file exists
and defines every imported name (submodule imports still resolve), instead of
trusting that the modules a harness happened to load are the ones that matter;
* the file walk in both AST passes is recursive, and the non-vacuity floors moved
with it (modules 10 → 20, relative_imports ≥ 100 — a shallow-glob regression
trips the floor instead of looking green);
* two permanent red proofs, both on a copy of the tree with the reverted
`from .helpers import discover_adb_host` reference in button.py: the stub-harness
sweep fails with "FAIL button.py", and the static pass fails with
"button.py:1: broken-relative-import: … not defined in helpers.py". Manual runs
confirm the engine's other direction on the real pre-fix tree (c588a7c: exit 1
with the t40 entity-contract finding) and exit 0 on HEAD.
No Home-Assistant API drift (23 modules, 58 imports, 148 relative imports, 36
call arguments, 6 awaited HA calls). Verified: 334 passed / 2 skipped, 335 / 1
with androidtvremote2, ruff clean.
Note (pre-existing, not introduced here): running a SUBSET of test files in an
order the suite was not written for (e.g. test_init.py alone after
test_standalone_setup.py) fails 3 import-step tests with
"ModuleNotFoundError: No module named 'voluptuous'" — the per-file stub scoping
assumes the suite's order. Verified identical on the pre-t41 tree (60d884b), so
it is left alone here.
`ConfigFlow._async_handle_discovery_without_unique_id` is an `async def` in core
(verified: `inspect.iscoroutinefunction` is True) and core awaits it. t32 called
it without `await`, so the coroutine body never ran: no `already_configured`
abort when the handler already had entries, no
`DEFAULT_DISCOVERY_UNIQUE_ID` claim, no `already_in_progress` abort — HA's
discovery dedup was dead, Python warned "coroutine ... was never awaited", and
the host-equality loop that used to sit here was the only accidental dedup.
The call is awaited and the now-unreachable host loop is gone (the real helper
is stricter: any entry for the domain, or any in-flight flow). The harness
mirrors the real contract instead of a sync no-op:
* `entity_stublib`'s hub stub makes the helper a coroutine (a sync stub would
turn the production `await` into a TypeError);
* `test_standalone_setup`'s stub models the real semantics — a coroutine raising
`DiscoveryDedupAbortError` (core raises AbortFlow), driven by the shared
holder's entry list and in-progress list — and its tests now assert all three
outcomes: awaited-and-proceeds (counter + claimed discovery unique id),
`already_configured` when entries exist, `already_in_progress` when another
flow runs. A fourth test collects the un-awaited-coroutine warning, and the
prefill test in the UX suite asserts the awaited counter.
Red proof: with the pre-fix config_flow.py the repo-owned sweep reports
`config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()`.
Verified: 289 passed, 2 skipped (D3 stage); ruff clean.
`homeassistant.util.diagnostic` does not exist — verified against a real
install: `import homeassistant.util.diagnostic` raises ModuleNotFoundError, and
the helper has always lived in `homeassistant.helpers.redact`. The import sat
inside a `try/except ImportError` that set the helper to None, so the
documented G14 pass — the one carrying `_TO_REDACT`, hosts included — silently
did nothing; only the marker scan (pairing-/key-shaped names) masked anything,
which is why the existing test's pairing-code assertion stayed green.
The helper is now imported at MODULE scope from homeassistant.helpers.redact and
applied unconditionally after the marker-name pass. A missing module is a loud
import error at integration load instead of a silent downgrade.
* the diagnostics test now asserts `adb_host` and `protocol_host` really are
masked in the emitted payload (options and a nested data value);
* TestFaithfulHaStubs asserts the module USES the helper from that path
(`nsh.diagnostics.async_redact_data is homeassistant.helpers.redact.async_redact_data`),
that no production module imports the non-existent module (AST), and that the
try/except ImportError fallback is gone;
* `entity_stublib` gains a faithful `homeassistant.helpers.redact` stub (same
masking, same nested mapping/list recursion, same None/empty-string skip) so
the harness meets the import the way a real install does.
Verified: 287 passed, 2 skipped (D2 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
`homeassistant.components.media_player` has not exported `BrowseMediaSource`
for years: the class lives in `homeassistant.components.media_source.models`,
where it exists to build `media_source://<domain>` ids and is the only place a
`domain` parameter belongs (t30 G18's note was about THAT class). The local
import therefore raised ImportError, and with it every real browse failed —
while the suite stayed green because the harness stubbed a `BrowseMediaSource`
that real HA does not expose from there.
`async_browse_media` now builds the standard tree exactly as core's own
androidtv_remote/media_player.py does: `BrowseMedia` imported from
homeassistant.components.media_player at module scope, every node carrying an
explicit media_content_id/media_content_type (MediaType.APP/APPS),
`children_media_class=MediaClass.APP` on the directory, and no `domain`.
Verified against a real install: the old import raises ImportError, the old
`domain=` kwarg raises TypeError, and the new node constructs.
The harness is now faithful so this cannot hide again:
* `entity_stublib` exposes a `BrowseMedia` with the real keyword-only
signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`,
and deliberately NOT `BrowseMediaSource` — so both the drifted import and the
drifted kwarg fail in-suite;
* the app-directory test asserts the standard shape (classes, explicit ids and
types, children_media_class, the serialised payload) instead of the removed
`domain` attribute;
* TestFaithfulHaStubs pins the harness contract on the AST (no production
module imports BrowseMediaSource) rather than on prose.
Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
"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.
F-A (HIGH): _user_schema_dict re-declared vol.Optional(CONF_ADB_HOST) /
CONF_PROTOCOL_HOST inside `if "host" in defaults:` — a no-op, because
voluptuous keys compare by field name and the first (defaultless) key object
survives. The discovered address therefore never reached the form, and the
user submitted a blank host. The host keys are now declared WITH default=host
(_host_field_key), so a discovery-driven user step arrives pre-filled.
F-A2: a discovery-driven user step says the address is known
("{discovery_note}Discovered Shield at <host> — its address is already filled
in below. ") through description_placeholders, and the no_state_source
re-render keeps the prefill instead of rebuilding a blank schema.
F-D: the ADB port is declared with default=DEFAULT_ADB_PORT (5555), and a YAML
seed's port hint is carried into the form instead of being flattened to 5555.
The seed host now feeds _seed_host (same prefill, no discovery claim).
Reproduce-through-test: tests/voluptuous_mirror.py mirrors the real
voluptuous.Marker key contract (hash/eq by field name, default=UNDEFINED when
omitted), because voluptuous is not installed in either CI variant and the old
bare-key stubs could not tell a schema WITH a default from one without. The
pre-t33 discovery assertion only checked the stored attribute and stayed green
while the form rendered blank; it now reads the schema keys and fails on the
pre-fix code (8 tests reproduced red locally).
Verified: uvx --with pytest-asyncio pytest tests -q → 242 passed, 1 skipped;
uvx ruff check custom_components tests → clean.