refactor(config): one Shield IP field for both transports (t41)

The two address fields were the same Shield spelled twice, and every bug in the
zeroconf/IP-change path came from keeping them in sync. One field now:

* const.CONF_HOST replaces CONF_ADB_HOST / CONF_PROTOCOL_HOST in the setup form,
  the options page, the pairing forms, the reconfigure/reauth seeding and the
  description copy. ADB port 5555 stays the only port field;
* helpers.entry_host(entry) is the single resolver (options first, then data);
  resolve_adb_config and the protocol settings both go through it, so both
  transports read the same value at call time (the t32 F1 factory re-read keeps
  working: the NEXT built poller sees the new address);
* helpers.seed_host / seed_adb_enabled own the YAML seed's shape rule. A block
  may use `host` or the legacy protocol_host/adb_host keys — t41 keeps the old
  spelling working — and t32 F4's outcome is unchanged: a protocol-only block
  leaves ADB off, an address (or an explicit adb_enabled) turns it on;
* the zeroconf IP-change path collapses the t21-F2 per-host dance into one rule:
  the discovery address IS the entry's address, so the entry is updated when it
  differs and left alone when it matches. The blank-self-heal and
  deliberately-different-ADB-host cases are gone with the duplication — a
  second address field cannot express them anymore, and the tests now pin that
  (previously "an explicit override is never clobbered");
* the options page exposes the protocol toggle it never had (the page could turn
  ADB off but not the protocol);
* button.py (granted to this task) reads the entry's own address instead of
  another integration's entity.

The v3 entry migration that maps existing adb_host/protocol_host onto CONF_HOST
and drops the androidtv key lands in the next commit; HA runs migrations before
setup, so an existing entry is never read with the old shape.

Verified: 311 passed / 2 skipped, 312 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD. The UI-copy contract tests (every rendered field
has a label in strings.json AND translations/en.json, key trees identical, host
errors name the field) drove the string edits.
This commit is contained in:
James Mackay committed 2026-10-11 06:18:22 +01:00
1 parent 86aa2827a2
commit 75ebeb40b0
12 files changed
+302 -276

No files matched your search

+60 -70
View File
@@ -15,19 +15,18 @@ from .const import (
ATTR_MEDIA_DAG_INPUT_MAP,
CONF_ADB_COMMAND_ENABLED,
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_HOST,
CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_SKIP_PAIRING,
@@ -35,7 +34,7 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
)
from .helpers import protocol_cert_paths
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
_LOGGER = logging.getLogger(__name__)
@@ -141,9 +140,10 @@ def _host_field_key(field: str, host: str):
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — every field belongs to THIS integration (t41).
``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`` pre-fills the name and the ONE address (t41) 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 "")
@@ -166,10 +166,9 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
# 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[_host_field_key(CONF_HOST, host)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, host)] = str
return schema_dict
@@ -251,10 +250,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
CONF_HOST: str(user_input.get(CONF_HOST) or "").strip(),
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
}
# If ADB is enabled, go to pairing step
@@ -336,22 +334,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for entry in self._async_current_entries():
if getattr(entry, "unique_id", None) != mac:
continue
# Known device — per-host updates (t21 F2): each host is
# tracked independently against the discovery address.
# - a blank (auto-detect) host self-heals from the address;
# - a stale protocol host is the IP change and is updated;
# - a deliberately-different ADB host (an explicit override
# that differs from both the address and the stored
# protocol host) is never clobbered.
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
adb_host = entry.options.get(CONF_ADB_HOST, "")
# Known device — IP change (t21 F2, collapsed by t41): there is
# ONE address field now, so the discovery address simply IS the
# entry's address. The per-host dance (blank self-heal / stale
# protocol host / deliberately different ADB host) only existed
# because the address was stored twice; it went with the
# duplication.
options = dict(entry.options)
if protocol_host != host:
options[CONF_PROTOCOL_HOST] = host
if not adb_host or adb_host == protocol_host:
# blank (self-heal) or auto-synced alongside the protocol
# host — safe to follow the device address on discovery
options[CONF_ADB_HOST] = host
if entry_host(entry) != host:
options[CONF_HOST] = host
if options != entry.options:
# t29 G3a: the deprecated update+schedule-reload combo is
# gone (2026.12 error) — the entry updates ONLY here; the
@@ -396,10 +387,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
CONF_HOST: d.get(CONF_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
},
)
@@ -409,9 +399,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_protocol_pair()
return self._create_entry()
def _protocol_host(self) -> str | None:
"""Protocol host: explicit override, else the ADB host fallback."""
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
def _discard_protocol_client(self) -> None:
"""Drop a pairing client whose session became void (host corrected)."""
@@ -429,7 +416,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
None when no host is resolvable or ``androidtvremote2`` is missing —
the caller turns that into an error key, never a crash.
"""
host = self._protocol_host()
host = self._host()
if not host:
return None
certfile, keyfile = await self.hass.async_add_executor_job(
@@ -451,7 +438,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_begin_protocol_pairing(self):
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
if not self._protocol_host():
if not self._host():
return None, "protocol_no_host"
client = await self._start_protocol_pairing()
if client is None:
@@ -470,7 +457,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_PAIRING_CODE): str,
_host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str,
_host_field_key(CONF_HOST, self._host()): str,
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
}
),
@@ -530,12 +517,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input.get(CONF_SKIP_PAIRING):
return await self._async_skip_protocol_pairing()
typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip()
if typed_host and typed_host != (self._protocol_host() or ""):
typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host and typed_host != self._host():
# 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._user_data[CONF_HOST] = typed_host
self._discard_protocol_client()
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
@@ -584,19 +571,17 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""
block = dict(data or {})
name = block.get("name") or DEFAULT_NAME
# the block's address comes from `host`, or from the legacy keys
block_host = seed_host(block)
seed_unique_id = block.get("unique_id")
if not seed_unique_id:
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
seed_unique_id = f"yaml:{name}:{block_host or 'nohost'}"
# duplicate guards
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
for existing in self._async_current_entries():
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
CONF_ADB_HOST
)
ehost = entry_host(existing)
if existing.unique_id == seed_unique_id or (
yhost and ehost == yhost
block_host and ehost == block_host
):
_LOGGER.info(
"nvidia_shield YAML seed for %r matches an existing "
@@ -612,18 +597,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# block actually carries an adb host.
self._user_data = {
"name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
# protocol prefill hints ONLY (pairing stays interactive)
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
# t32 F4's shape rule on the t41 keys: an address names the Shield,
# and an explicit adb_enabled says otherwise
CONF_ADB_ENABLED: seed_adb_enabled(block),
CONF_HOST: block_host,
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
}
# 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
]
self._seed_host = block_host
return await self.async_step_user(None)
async def async_step_reauth(self, entry_data):
@@ -644,7 +627,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = {
"name": entry.title,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
CONF_HOST: entry_host(entry),
}
return await self.async_step_protocol_pair() # first visit re-pairs
return self.async_show_form(
@@ -671,10 +654,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = {
"name": entry.title,
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
CONF_HOST: entry_host(entry),
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()
@@ -691,7 +673,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"options": {
**entry.options,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._protocol_host() or "",
CONF_HOST: self._host(),
},
}
if mac:
@@ -714,19 +696,25 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
options={
**entry.options,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
CONF_HOST: self._host(),
},
)
self._reauth_entry = None
return self.async_abort(reason="reauth_successful")
def _resolve_adb_host(self) -> str | None:
"""The address to use for ADB: the ADB field, else the protocol field."""
return (
self._user_data.get(CONF_ADB_HOST)
or self._user_data.get(CONF_PROTOCOL_HOST)
or None
)
def _host(self) -> str:
"""The Shield's address as this flow knows it (t41: ONE field).
What the user typed (or a seed carried) lives in ``_user_data``; the
discovery/seed hints stay as fallbacks for a form that was rendered with
a pre-filled field and submitted unchanged.
"""
return str(
self._user_data.get(CONF_HOST)
or self._discovered_host
or self._seed_host
or ""
).strip()
def _show_adb_pair_form(self, errors=None):
"""adb_pair form: the address/port (fixable here) and the skip escape.
@@ -739,7 +727,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="adb_pair",
data_schema=vol.Schema(
{
_host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str,
_host_field_key(CONF_HOST, self._host()): str,
vol.Optional(
CONF_ADB_PORT,
default=self._user_data.get(
@@ -770,10 +758,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# re-adding the integration.
return await self._async_after_pairing()
typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip()
typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host:
# a host typed HERE is authoritative (the user step is gone)
self._user_data[CONF_ADB_HOST] = typed_host
self._user_data[CONF_HOST] = typed_host
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
try:
@@ -782,7 +770,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
port = DEFAULT_ADB_PORT
self._user_data[CONF_ADB_PORT] = port
host = self._resolve_adb_host()
host = self._host()
if not host:
errors["base"] = "adb_no_host"
else:
@@ -923,12 +911,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"]
# ADB options
# Transport toggles (t41: both transports are ours; the skip paths and
# this page are the way to turn either one off)
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
# ADB host/port (optional overrides, shown when ADB is enabled)
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
# The ONE address field (t41), plus the ADB port
schema_dict[vol.Optional(CONF_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
# t29 G3b: suggested values come from the entry options via the