The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.
Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
and, with it, every registry lookup for another integration's devices — the
user step now sets NO unique_id at all; the protocol MAC learned during
pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
resolve_app_name chain, the Current App sensor, the sensor's state-change
listener and its `_androidtv_state` helper) — the manager's store is the only
source;
* the "transitional external entity" command tier in every path: `_cmd` and the
pan-media_player service call are gone, and so is the fallback in _media_cmd,
_remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
(an intent no transport served now surrenders to the truth instead of
delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
intent" check no longer watches another integration's entity; the power
LADDER — protocol is_on → ADB wake → power switch — is unchanged).
turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.
helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.
Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.
Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
`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.
The F1 test asserted the updated options; this closes the loop on the user
outcome — after the Reconfigure pairing, __init__._build_protocol_settings
returns settings (transport ON) pointing at the paired certificate under
.storage/nvidia_shield/ and at the address that just paired. That is why no
__init__.py change was needed: the reloaded entry already rebuilds the
transport from the persisted options + shared cert paths.
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.
"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.
real voluptuous 0.16.0 implements ``Marker.__eq__`` as ``self.schema ==
other`` — a field-name comparison with no class check, so
``Optional("x") == Required("x")`` is True (verified against the library
source, not just the audit note). The mirror compared class + field, which
kept two dict entries for one field: a ``Required(field, default=...)`` after
``Optional(field)`` LOOKED like it applied the default, while real voluptuous
keeps the first (defaultless) key object and silently drops it — the exact
t33 F-A failure mode the mirror exists to catch, i.e. the harness could mask
the bug it was built to see.
``Marker.__eq__`` is now the library's comparison and the contract tests gained
both pins: field-only equality (with equal hashes) and the mixed-marker
re-declaration that used to be masked. Reverting only ``__eq__`` makes those
two fail, which is the red proof recorded for t35.
Verified: uvx --with pytest-asyncio pytest tests -q → 276 passed, 1 skipped;
uvx ruff check custom_components tests → clean.
One home for the user-reported regressions, all driven end to end against the
scoped stub harness:
- TestVoluptuousMirrorContract pins tests/voluptuous_mirror.py itself (a
default on the declaration is visible; a later re-declaration cannot add one)
so the harness cannot silently go blind again.
- TestDiscoveryPrefill: the discovered address is the default of BOTH host
keys, the port default is 5555, the step announces "Discovered Shield at
<host>", a manual/seed flow claims no discovery, and the error re-render
keeps the prefill. Reads the rendered schema, not the stored attribute.
- TestNoDeadEndPairing: skip protocol → entry created (passive), skip ADB →
entry created (ADB intent kept for the Pair ADB button) with the real
async_setup_entry accepting both entries (no ConfigEntryNotReady), skip ADB
routes on to the protocol step, both normal pair paths still create the
entry, a host typed on a pairing form is accepted in place, and a missing
androidtvremote2 reports protocol_unavailable instead of raising.
- TestCopyContract: no copy points at "the previous step", the no_host errors
name the field and the discovery prefill, every error/abort key raised by
config_flow.py exists in both copy files, and every field rendered by the
user/adb_pair/protocol_pair/options forms has a label.
- TestStringsTranslationsParity: strings.json and translations/en.json expose
the same key tree.
- TestPassiveSetupNeverDemandsReadiness: async_setup_entry raises nothing.
Verified: uvx --with pytest-asyncio pytest tests -q → 274 passed, 1 skipped;
uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 275 passed;
uvx ruff check custom_components tests → clean.