fix(config_flow): F-A/F-A2/F-D — the discovered host and the 5555 port ride the schema KEY defaults (t33)

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.
This commit is contained in:
James Mackay committed 2026-10-10 20:50:16 +01:00
1 parent bbd98ebc3f
commit 73e886dafe
6 files changed
+193 -37

No files matched your search

+71 -25
View File
@@ -154,12 +154,31 @@ def _app_map_from_text(
return app_map
def _host_field_key(field: str, host: str):
"""The host-field KEY, carrying the known address as its default.
t33 F-A: the default MUST ride on this key. Re-declaring a bare
``vol.Optional(field)`` afterwards is a no-op that silently drops it —
voluptuous keys compare by field name, so the first (defaultless) key
object survives and the form renders blank.
"""
if host:
return vol.Optional(field, default=host)
return vol.Optional(field)
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — the androidtv entity is optional (standalone).
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
reads as an advanced setting rather than required knowledge (t33 F-D).
"""
defaults = defaults or {}
host = str(defaults.get("host") or "")
port = defaults.get("port")
if not isinstance(port, int):
port = DEFAULT_ADB_PORT
schema_dict = {
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
@@ -174,14 +193,13 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
# the code's own fallback for this flag is True (async_step_user) — the
# rendered checkbox must agree with it
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
schema_dict[_host_field_key(CONF_ADB_HOST, host)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
if "host" in defaults:
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, host)] = str
return schema_dict
@@ -215,6 +233,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# Zeroconf discovery state (P4-1)
self._discovered_host: str | None = None
self._discovered_name: str | None = None
# YAML-seed prefill hint (t33 F-A): feeds the same user-step prefill as
# a discovery, without claiming the address was discovered.
self._seed_host: str | None = None
async def async_step_user(self, user_input=None):
"""Step 1: Basic setup.
@@ -236,12 +257,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
if not has_source:
errors["base"] = "no_state_source"
sel = _build_selectors()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(_user_schema_dict(sel)),
errors=errors,
)
return self._show_user_form(errors)
# t29 G2: the androidtv entity_id is a renameable reference —
# never a unique_id. A sqlite/registry MAC is preferred (the
@@ -301,21 +317,48 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self._create_entry()
return self._show_user_form(errors)
sel = _build_selectors()
def _user_defaults(self) -> dict:
"""Defaults for the user step: whatever setup already knows.
defaults = {}
A zeroconf discovery (or a YAML seed hint) supplies the address; the
port keeps a seed's hint instead of being flattened to the standard
value. This is the ONLY place the known address enters the form — the
schema keys carry these values as their defaults (t33 F-A).
"""
defaults = {
"name": self._discovered_name
or self._user_data.get("name")
or DEFAULT_NAME,
}
host = self._discovered_host or self._seed_host
if host:
defaults["host"] = host
port = self._user_data.get(CONF_ADB_PORT)
if isinstance(port, int):
defaults["port"] = port
return defaults
def _show_user_form(self, errors=None):
"""Render the user step with the known address actually applied.
t33 F-A2: a discovery-driven form says so in its description, so a
pre-filled host can never be misread as "unknown".
"""
note = ""
if self._discovered_host:
defaults = {
"name": self._discovered_name or DEFAULT_NAME,
"host": self._discovered_host,
}
schema_dict = _user_schema_dict(sel, defaults)
note = (
f"Discovered Shield at {self._discovered_host} — its address "
"is already filled in below. "
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema_dict),
errors=errors,
data_schema=vol.Schema(
_user_schema_dict(_build_selectors(), self._user_defaults())
),
errors=errors or {},
description_placeholders={"discovery_note": note},
)
async def async_step_zeroconf(self, discovery_info):
@@ -530,7 +573,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# protocol prefill hints ONLY (pairing stays interactive)
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
}
self._discovered_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
# t33 F-A: the seed host feeds the same user-step prefill as a
# zeroconf discovery, but WITHOUT the discovery announcement — the
# address came from configuration.yaml, not from the network.
self._seed_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
CONF_ADB_HOST
]
return await self.async_step_user(None)
+1 -1
View File
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"description": "{discovery_note}Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": {
"name": "Device Name",
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"description": "{discovery_note}Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": {
"name": "Device Name",
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",