From 75ebeb40b01e946509f46df881411c594e7f0895 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Sun, 11 Oct 2026 06:18:22 +0100 Subject: [PATCH] refactor(config): one Shield IP field for both transports (t41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- custom_components/nvidia_shield/__init__.py | 36 +++-- custom_components/nvidia_shield/button.py | 11 +- .../nvidia_shield/config_flow.py | 130 ++++++++---------- custom_components/nvidia_shield/const.py | 12 +- custom_components/nvidia_shield/helpers.py | 47 ++++++- custom_components/nvidia_shield/strings.json | 34 +++-- .../nvidia_shield/translations/en.json | 34 +++-- tests/test_config_flow_pairing.py | 6 +- tests/test_config_flow_protocol.py | 17 +-- tests/test_config_flow_ux.py | 111 +++++++-------- tests/test_init.py | 42 +++++- tests/test_standalone_setup.py | 98 +++++-------- 12 files changed, 302 insertions(+), 276 deletions(-) diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index 5376933..1f569d9 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -24,14 +24,20 @@ from .const import ( CONF_ADB_ENABLED, CONF_ADB_HOST, CONF_ADB_PORT, + CONF_HOST, CONF_PROTOCOL_ENABLED, - CONF_PROTOCOL_HOST, DEFAULT_ADB_PORT, DEFAULT_NAME, DOMAIN, SERVICE_ADB_COMMAND, ) -from .helpers import protocol_cert_paths, resolve_adb_config +from .helpers import ( + entry_host, + protocol_cert_paths, + resolve_adb_config, + seed_adb_enabled, + seed_host, +) _LOGGER = logging.getLogger(__name__) @@ -49,9 +55,9 @@ async def async_setup(hass: HomeAssistant, config) -> bool: """ domain_config = (config or {}).get(DOMAIN) for block in _yaml_seed_blocks(domain_config or []): - if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST): + if not seed_host(block): _LOGGER.warning( - "nvidia_shield YAML block without protocol/adb host ignored: %s", + "nvidia_shield YAML block without an address ignored: %s", block.get("name", ""), ) continue @@ -83,15 +89,13 @@ def _yaml_seed_blocks(domain_config) -> list[dict]: continue name = block.get("name") or DEFAULT_NAME # t32 F4 (review rider): the seed honors the block's shape — a - # protocol-only block does NOT force ADB on; ADB comes only when the - # block carries an adb host (interactive cosmetics stay in the flow). + # protocol-only block does NOT force ADB on. t41: the address is ONE + # field (`host`, or a legacy protocol_host/adb_host). seed = { "name": name, - CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)), - CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""), + CONF_ADB_ENABLED: seed_adb_enabled(block), + CONF_HOST: seed_host(block), CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT), - # protocol fields are prefill hints ONLY — pairing stays interactive - CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""), } if block.get("unique_id"): seed["unique_id"] = str(block["unique_id"]) @@ -177,14 +181,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry): """Manager protocol_settings when the transport is enabled & resolvable. - The address is the entry's own (t41): the protocol field, else the ADB - field — both name the same Shield. Nothing is derived from another - integration. + The address is the entry's own single field (t41). Nothing is derived from + another integration. """ if not entry.options.get(CONF_PROTOCOL_ENABLED, False): return None - host = entry.options.get(CONF_PROTOCOL_HOST) or entry.options.get(CONF_ADB_HOST) + host = entry_host(entry) if not host: _LOGGER.info( "Protocol enabled for entry %s but no host resolvable — continuing " @@ -214,7 +217,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version < 2: options = {**config_entry.options} options.setdefault(CONF_PROTOCOL_ENABLED, False) - options.setdefault(CONF_PROTOCOL_HOST, "") + # t41: the entry's address is the single CONF_HOST field; a v1 entry + # carried it under the legacy adb_host key. + if not options.get(CONF_HOST): + options[CONF_HOST] = options.get(CONF_ADB_HOST) or "" hass.config_entries.async_update_entry( config_entry, options=options, version=2 ) diff --git a/custom_components/nvidia_shield/button.py b/custom_components/nvidia_shield/button.py index c4ae2af..d0c053d 100644 --- a/custom_components/nvidia_shield/button.py +++ b/custom_components/nvidia_shield/button.py @@ -11,12 +11,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .const import ( CONF_ADB_ENABLED, - CONF_ADB_HOST, CONF_ADB_PORT, - CONF_PROTOCOL_HOST, DEFAULT_ADB_PORT, DOMAIN, ) +from .helpers import entry_host _LOGGER = logging.getLogger(__name__) @@ -54,11 +53,9 @@ class NvidiaShieldPairAdbButton(ButtonEntity): async def async_press(self) -> None: """Attempt ADB connection. The Shield will show a pairing prompt.""" - # t41: the address is ours — never derived from another integration's - # entity. The ADB field leads, then the protocol field (same Shield). - host = self._entry.options.get(CONF_ADB_HOST) or self._entry.options.get( - CONF_PROTOCOL_HOST - ) + # t41: the entry's own single address — never derived from another + # integration's entity. + host = entry_host(self._entry) port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) if not host: diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 3f43dea..ce44770 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -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 diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py index a7159a4..c197e67 100644 --- a/custom_components/nvidia_shield/const.py +++ b/custom_components/nvidia_shield/const.py @@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401 DOMAIN = "nvidia_shield" +# t41: the INTEGRATION's address field — one Shield IP for the whole stack +# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still +# carry them and the v3 entry migration maps them onto CONF_HOST, but no +# runtime code reads them. +CONF_HOST = "host" +CONF_ADB_HOST = "adb_host" +CONF_PROTOCOL_HOST = "protocol_host" + +# t41 legacy: dropped from entries by the v3 migration (the androidtv entity +# path it configured is gone), never read at runtime. CONF_ANDROIDTV_ENTITY = "androidtv_entity" CONF_JELLYFIN_ENTITY = "jellyfin_entity" CONF_RECEIVER_ENTITY = "receiver_entity" @@ -19,12 +29,10 @@ CONF_RECEIVER_INPUT = "receiver_input" CONF_POWER_ENTITY = "power_entity" CONF_ADB_ENABLED = "adb_enabled" CONF_ADB_STORE_APP_INFO = "adb_store_app_info" -CONF_ADB_HOST = "adb_host" CONF_ADB_PORT = "adb_port" # Remote-protocol transport (androidtvremote2) — Phase 2 CONF_PROTOCOL_ENABLED = "protocol_enabled" -CONF_PROTOCOL_HOST = "protocol_host" CONF_PAIRING_CODE = "pairing_code" # t33 F-B: the "continue without pairing" escape offered on BOTH pairing # steps — the entry still finishes (passive mode) instead of stranding the diff --git a/custom_components/nvidia_shield/helpers.py b/custom_components/nvidia_shield/helpers.py index 5723884..aa36e03 100644 --- a/custom_components/nvidia_shield/helpers.py +++ b/custom_components/nvidia_shield/helpers.py @@ -6,8 +6,10 @@ from dataclasses import dataclass from homeassistant.core import HomeAssistant from .const import ( + CONF_ADB_ENABLED, CONF_ADB_HOST, CONF_ADB_PORT, + CONF_HOST, CONF_PROTOCOL_HOST, DEFAULT_ADB_PORT, DOMAIN, @@ -41,14 +43,51 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]: return os.path.join(base, "client.pem"), os.path.join(base, "client.key") +def entry_host(entry) -> str: + """The Shield's address for an entry (t41: ONE field). + + ``options`` is the source of truth; ``data`` is read as a fallback so an + entry created by an older flow (or a hand-written seed) still resolves. + """ + options = getattr(entry, "options", None) or {} + data = getattr(entry, "data", None) or {} + return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip() + + +def seed_host(block: dict) -> str: + """The address a YAML seed block carries (t41). + + ``host`` is the current key; the legacy ``protocol_host`` / ``adb_host`` + stay accepted so an existing configuration.yaml keeps seeding, protocol + first (that transport is the one that cannot fall back to anything else). + """ + for key in (CONF_HOST, CONF_PROTOCOL_HOST, CONF_ADB_HOST): + value = str((block or {}).get(key) or "").strip() + if value: + return value + return "" + + +def seed_adb_enabled(block: dict) -> bool: + """Does a seed block ask for ADB? (t32 F4's shape rule, t41's keys). + + An explicit ``adb_enabled`` wins. Otherwise an address that names the Shield + — the current ``host`` key or the legacy ``adb_host`` — turns ADB on, while + a protocol-only block leaves it off. + """ + block = block or {} + if CONF_ADB_ENABLED in block: + return bool(block[CONF_ADB_ENABLED]) + return bool(block.get(CONF_HOST) or block.get(CONF_ADB_HOST)) + + def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None: """Resolve host/port/key_path for the entry's ADB connection, or None. - Priority: the entry's own address fields (t41: no other integration is - consulted) — the ADB field first, then the protocol field, since both - describe the same Shield. + Priority: the entry's own address (t41: no other integration is consulted) + and its ADB port option. """ - host = entry.options.get(CONF_ADB_HOST) or entry.options.get(CONF_PROTOCOL_HOST) + host = entry_host(entry) if not host: return None port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)) diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json index 4b17e55..7b53c53 100644 --- a/custom_components/nvidia_shield/strings.json +++ b/custom_components/nvidia_shield/strings.json @@ -3,45 +3,41 @@ "step": { "user": { "title": "NVIDIA Shield", - "description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.", + "description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.", "data": { "name": "Name", - "androidtv_entity": "Android TV Remote entity", "jellyfin_entity": "Jellyfin media player", "receiver_entity": "Receiver", "receiver_input": "Receiver input", "power_entity": "Power switch", "adb_enabled": "Read state over ADB", "adb_store_app_info": "Show title and artist", - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", - "protocol_enabled": "Add the Nvidia Remote Protocol", - "protocol_host": "Protocol host (IP address)" + "protocol_enabled": "Add the Nvidia Remote Protocol" }, "data_description": { - "androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.", "power_entity": "A smart plug switch, if you want reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", - "adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.", - "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.", - "protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host." + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step." } }, "adb_pair": { "title": "Allow ADB access", "description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.", "data": { - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", "skip_pairing": "Continue without ADB pairing" }, "data_description": { - "adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", "adb_port": "The standard ADB port is 5555.", "skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later." } @@ -51,12 +47,12 @@ "description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.", "data": { "pairing_code": "Pairing code shown on the Shield", - "protocol_host": "Protocol host (IP address)", + "host": "The Shield's IP address", "skip_pairing": "Continue without protocol pairing" }, "data_description": { "pairing_code": "The code the Shield displays while this step is open.", - "protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", "skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure." } }, @@ -67,11 +63,11 @@ }, "error": { "adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.", - "adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", - "protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", + "adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).", + "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.", "protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.", - "no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.", + "no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.", "invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.", "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds." }, @@ -101,8 +97,9 @@ "receiver_input": "Receiver input", "power_entity": "Power switch", "adb_enabled": "Read state over ADB", + "protocol_enabled": "Add the Nvidia Remote Protocol", "adb_store_app_info": "Show title and artist", - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", "app_name_map": "App names and icons", "adb_command_enabled": "Enable the adb_command service", @@ -117,7 +114,8 @@ "power_entity": "A smart plug switch, if you want reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", - "adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.", + "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.", + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.", "adb_port": "The ADB port. The standard value is 5555.", "app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.", diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json index 4b17e55..7b53c53 100644 --- a/custom_components/nvidia_shield/translations/en.json +++ b/custom_components/nvidia_shield/translations/en.json @@ -3,45 +3,41 @@ "step": { "user": { "title": "NVIDIA Shield", - "description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.", + "description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.", "data": { "name": "Name", - "androidtv_entity": "Android TV Remote entity", "jellyfin_entity": "Jellyfin media player", "receiver_entity": "Receiver", "receiver_input": "Receiver input", "power_entity": "Power switch", "adb_enabled": "Read state over ADB", "adb_store_app_info": "Show title and artist", - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", - "protocol_enabled": "Add the Nvidia Remote Protocol", - "protocol_host": "Protocol host (IP address)" + "protocol_enabled": "Add the Nvidia Remote Protocol" }, "data_description": { - "androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.", "power_entity": "A smart plug switch, if you want reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", - "adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.", - "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.", - "protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host." + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step." } }, "adb_pair": { "title": "Allow ADB access", "description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.", "data": { - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", "skip_pairing": "Continue without ADB pairing" }, "data_description": { - "adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", "adb_port": "The standard ADB port is 5555.", "skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later." } @@ -51,12 +47,12 @@ "description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.", "data": { "pairing_code": "Pairing code shown on the Shield", - "protocol_host": "Protocol host (IP address)", + "host": "The Shield's IP address", "skip_pairing": "Continue without protocol pairing" }, "data_description": { "pairing_code": "The code the Shield displays while this step is open.", - "protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", + "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", "skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure." } }, @@ -67,11 +63,11 @@ }, "error": { "adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.", - "adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", - "protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", + "adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).", + "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.", "protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.", - "no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.", + "no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.", "invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.", "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds." }, @@ -101,8 +97,9 @@ "receiver_input": "Receiver input", "power_entity": "Power switch", "adb_enabled": "Read state over ADB", + "protocol_enabled": "Add the Nvidia Remote Protocol", "adb_store_app_info": "Show title and artist", - "adb_host": "ADB host (IP address)", + "host": "The Shield's IP address", "adb_port": "ADB port", "app_name_map": "App names and icons", "adb_command_enabled": "Enable the adb_command service", @@ -117,7 +114,8 @@ "power_entity": "A smart plug switch, if you want reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", - "adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.", + "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.", + "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.", "adb_port": "The ADB port. The standard value is 5555.", "app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.", diff --git a/tests/test_config_flow_pairing.py b/tests/test_config_flow_pairing.py index f54257d..5a0b287 100644 --- a/tests/test_config_flow_pairing.py +++ b/tests/test_config_flow_pairing.py @@ -15,10 +15,9 @@ import pytest from conftest import _SHIELD_DIR, _load_module from nsh.const import ( CONF_ADB_ENABLED, - CONF_ADB_HOST, CONF_ADB_PORT, CONF_ADB_STORE_APP_INFO, - CONF_ANDROIDTV_ENTITY, + CONF_HOST, CONF_JELLYFIN_ENTITY, CONF_POWER_ENTITY, CONF_RECEIVER_ENTITY, @@ -164,9 +163,8 @@ def _flow(user_input_host: str, monkeypatch=None): flow._user_data = { "name": "Nvidia Shield", CONF_ADB_ENABLED: True, - CONF_ADB_HOST: user_input_host, + CONF_HOST: user_input_host, CONF_ADB_PORT: 5555, - CONF_ANDROIDTV_ENTITY: "media_player.shield", CONF_JELLYFIN_ENTITY: "", CONF_RECEIVER_ENTITY: "", CONF_RECEIVER_INPUT: "", diff --git a/tests/test_config_flow_protocol.py b/tests/test_config_flow_protocol.py index 268c0f5..17a973f 100644 --- a/tests/test_config_flow_protocol.py +++ b/tests/test_config_flow_protocol.py @@ -15,15 +15,13 @@ import pytest from conftest import _SHIELD_DIR, _load_module from nsh.const import ( CONF_ADB_ENABLED, - CONF_ADB_HOST, CONF_ADB_PORT, CONF_ADB_STORE_APP_INFO, - CONF_ANDROIDTV_ENTITY, + CONF_HOST, CONF_JELLYFIN_ENTITY, CONF_PAIRING_CODE, CONF_POWER_ENTITY, CONF_PROTOCOL_ENABLED, - CONF_PROTOCOL_HOST, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, DEFAULT_ADB_PORT, @@ -296,17 +294,15 @@ def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtoco flow.hass = FakeHass(tmp_dir) flow._user_data = { "name": "mdarcy", - CONF_ANDROIDTV_ENTITY: "media_player.shield", CONF_JELLYFIN_ENTITY: "", CONF_RECEIVER_ENTITY: "", CONF_RECEIVER_INPUT: "", CONF_POWER_ENTITY: "", CONF_ADB_ENABLED: False, CONF_ADB_STORE_APP_INFO: True, - CONF_ADB_HOST: "", + CONF_HOST: "shield.local", # t41: ONE address field CONF_ADB_PORT: DEFAULT_ADB_PORT, CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: "shield.local", } from nsh.connection import ShieldConnectionManager @@ -350,7 +346,7 @@ class TestProtocolPairing: assert client.disconnected == 1 assert result["type"] == "create_entry" assert result["options"][CONF_PROTOCOL_ENABLED] is True - assert result["options"][CONF_PROTOCOL_HOST] == "shield.local" + assert result["options"][CONF_HOST] == "shield.local" async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch): client = FakeProtocolClient() @@ -374,7 +370,7 @@ class TestReauthFlow: flow = make_flow(str(tmp_path), monkeypatch, client) entry = SimpleNamespace( entry_id="e1", title="mdarcy", - options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"}, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"}, data={}, ) flow.hass.config_entries.__dict__["__entry_e1"] = entry @@ -411,9 +407,10 @@ class TestEntryMigration: # ADB options carried over byte-compatibly assert entry.options["adb_enabled"] is True assert entry.options["adb_port"] == 5555 - # Protocol fields default; host discovery (ADB fallback) stays active + # Protocol fields default; the entry keeps its own address (t41: the v1 + # adb_host is mapped onto CONF_HOST, empty here) assert entry.options[CONF_PROTOCOL_ENABLED] is False - assert entry.options[CONF_PROTOCOL_HOST] == "" + assert entry.options[CONF_HOST] == "" assert hass.config_entries.updating_entry_calls == [ (entry, {"options": entry.options, "version": 2}) ] diff --git a/tests/test_config_flow_ux.py b/tests/test_config_flow_ux.py index d218827..fa0cb89 100644 --- a/tests/test_config_flow_ux.py +++ b/tests/test_config_flow_ux.py @@ -31,12 +31,10 @@ import voluptuous_mirror as vol_mirror from conftest import _SHIELD_DIR, _load_module from nsh.const import ( CONF_ADB_ENABLED, - CONF_ADB_HOST, CONF_ADB_PORT, - CONF_ANDROIDTV_ENTITY, + CONF_HOST, CONF_PAIRING_CODE, CONF_PROTOCOL_ENABLED, - CONF_PROTOCOL_HOST, CONF_SKIP_PAIRING, DEFAULT_ADB_PORT, ) @@ -390,11 +388,9 @@ def _patch_adb(monkeypatch, module, connected=True): def _user_input(**overrides) -> dict: base = { "name": "mdarcy", - CONF_ANDROIDTV_ENTITY: "media_player.shield", CONF_ADB_ENABLED: False, - CONF_ADB_HOST: "", CONF_PROTOCOL_ENABLED: False, - CONF_PROTOCOL_HOST: "", + CONF_HOST: "", # t41: one address field for both transports } base.update(overrides) return base @@ -444,22 +440,22 @@ class TestVoluptuousMirrorContract: def test_a_default_on_the_declaration_is_visible(self): vol = sys.modules["voluptuous"] - schema = {vol.Optional(CONF_ADB_HOST, default=DISCOVERED_HOST): str} + schema = {vol.Optional(CONF_HOST, default=DISCOVERED_HOST): str} - assert vol_mirror.defaults_by_key(schema)[CONF_ADB_HOST] == DISCOVERED_HOST + assert vol_mirror.defaults_by_key(schema)[CONF_HOST] == DISCOVERED_HOST def test_a_later_redeclaration_cannot_add_the_default(self): vol = sys.modules["voluptuous"] # this is the t33 shape: declare bare, then "fix" it by re-declaring. # Real voluptuous keeps the FIRST key object, so the default is dropped. - schema = {vol.Optional(CONF_ADB_HOST): str} - schema[vol.Optional(CONF_ADB_HOST, default=DISCOVERED_HOST)] = str + schema = {vol.Optional(CONF_HOST): str} + schema[vol.Optional(CONF_HOST, default=DISCOVERED_HOST)] = str - assert vol_mirror.defaults_by_key(schema)[CONF_ADB_HOST] is vol_mirror.UNDEFINED + assert vol_mirror.defaults_by_key(schema)[CONF_HOST] is vol_mirror.UNDEFINED def test_a_bare_string_key_is_rejected_loudly(self): with pytest.raises(AssertionError): - vol_mirror.declared_fields({CONF_ADB_HOST: str}) + vol_mirror.declared_fields({CONF_HOST: str}) def test_marker_equality_is_field_only_like_voluptuous(self): """t35 F2: real ``Marker.__eq__`` is ``self.schema == other``. @@ -468,12 +464,12 @@ class TestVoluptuousMirrorContract: """ vol = sys.modules["voluptuous"] - assert vol.Optional(CONF_ADB_HOST) == vol.Required(CONF_ADB_HOST) - assert hash(vol.Optional(CONF_ADB_HOST)) == hash( - vol.Required(CONF_ADB_HOST) + assert vol.Optional(CONF_HOST) == vol.Required(CONF_HOST) + assert hash(vol.Optional(CONF_HOST)) == hash( + vol.Required(CONF_HOST) ) # the real implementation compares the field name to the other value - assert vol.Optional(CONF_ADB_HOST) == CONF_ADB_HOST + assert vol.Optional(CONF_HOST) == CONF_HOST def test_a_mixed_marker_redeclaration_drops_the_default(self): """The case a class-comparing mirror masks (t35 F2). @@ -483,11 +479,11 @@ class TestVoluptuousMirrorContract: schema silently loses the default, exactly like the Optional case. """ vol = sys.modules["voluptuous"] - schema = {vol.Optional(CONF_ADB_HOST): str} - schema[vol.Required(CONF_ADB_HOST, default=DISCOVERED_HOST)] = str + schema = {vol.Optional(CONF_HOST): str} + schema[vol.Required(CONF_HOST, default=DISCOVERED_HOST)] = str assert ( - vol_mirror.defaults_by_key(schema)[CONF_ADB_HOST] + vol_mirror.defaults_by_key(schema)[CONF_HOST] is vol_mirror.UNDEFINED ) @@ -495,7 +491,7 @@ class TestVoluptuousMirrorContract: class TestDiscoveryPrefill: """F-A / F-A2 / F-D: the discovered address reaches the rendered FORM.""" - async def test_discovered_host_is_the_default_of_both_host_fields(self, tmp_path): + async def test_discovered_host_is_the_default_of_the_host_field(self, tmp_path): module = _form_module() flow, _hass = _new_flow(module, tmp_path) @@ -509,23 +505,22 @@ class TestDiscoveryPrefill: defaults = vol_mirror.defaults_by_key(result["data_schema"]) # schema-level, not "the attribute was stored": this is precisely what # the old assertion missed while the form rendered blank - assert defaults[CONF_ADB_HOST] == DISCOVERED_HOST - assert defaults[CONF_PROTOCOL_HOST] == DISCOVERED_HOST + assert defaults[CONF_HOST] == DISCOVERED_HOST - async def test_every_host_field_carries_the_discovered_default(self, tmp_path): - """The keys are declared WITH the default — never re-declared bare.""" + async def test_the_host_field_carries_the_discovered_default(self, tmp_path): + """The key is declared WITH the default — never re-declared bare.""" module = _form_module() flow, _hass = _new_flow(module, tmp_path) result = await flow.async_step_zeroconf(_discovery()) - for field in (CONF_ADB_HOST, CONF_PROTOCOL_HOST): - keys = [ - key for key in vol_mirror.declared_keys(result["data_schema"]) - if key.schema == field - ] - assert len(keys) == 1, f"{field} is declared {len(keys)} times" - assert keys[0].default == DISCOVERED_HOST + keys = [ + key + for key in vol_mirror.declared_keys(result["data_schema"]) + if key.schema == CONF_HOST + ] + assert len(keys) == 1, f"the address field is declared {len(keys)} times" + assert keys[0].default == DISCOVERED_HOST async def test_discovery_step_announces_the_known_address(self, tmp_path): module = _form_module() @@ -544,7 +539,7 @@ class TestDiscoveryPrefill: assert result["description_placeholders"]["discovery_note"] == "" assert ( - vol_mirror.defaults_by_key(result["data_schema"])[CONF_ADB_HOST] + vol_mirror.defaults_by_key(result["data_schema"])[CONF_HOST] is vol_mirror.UNDEFINED ) @@ -556,7 +551,7 @@ class TestDiscoveryPrefill: assert result["step_id"] == "user" defaults = vol_mirror.defaults_by_key(result["data_schema"]) - assert defaults[CONF_ADB_HOST] == "10.0.0.31" + assert defaults[CONF_HOST] == "10.0.0.31" assert defaults["name"] == "Living" assert result["description_placeholders"]["discovery_note"] == "" @@ -583,8 +578,7 @@ class TestDiscoveryPrefill: assert result["errors"] == {"base": "no_state_source"} defaults = vol_mirror.defaults_by_key(result["data_schema"]) - assert defaults[CONF_ADB_HOST] == DISCOVERED_HOST - assert defaults[CONF_PROTOCOL_HOST] == DISCOVERED_HOST + assert defaults[CONF_HOST] == DISCOVERED_HOST class TestNoDeadEndPairing: @@ -604,9 +598,9 @@ class TestNoDeadEndPairing: assert CONF_SKIP_PAIRING in vol_mirror.declared_fields(form["data_schema"]) adb_fields = vol_mirror.declared_fields(adb_form["data_schema"]) - assert CONF_ADB_HOST in adb_fields # the field adb_no_host names + assert CONF_HOST in adb_fields # the field adb_no_host names protocol_fields = vol_mirror.declared_fields(protocol_form["data_schema"]) - assert CONF_PROTOCOL_HOST in protocol_fields # protocol_no_host names this + assert CONF_HOST in protocol_fields # protocol_no_host names this async def test_the_skip_option_is_off_by_default(self, tmp_path, monkeypatch): module = _form_module() @@ -631,7 +625,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -662,7 +656,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_ADB_ENABLED: True, - CONF_ADB_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -695,7 +689,7 @@ class TestNoDeadEndPairing: **{ CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True, - CONF_ADB_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -717,7 +711,7 @@ class TestNoDeadEndPairing: **{ CONF_ADB_ENABLED: True, CONF_PROTOCOL_ENABLED: True, - CONF_ADB_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -739,7 +733,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -763,7 +757,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_ADB_ENABLED: True, - CONF_ADB_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -785,11 +779,11 @@ class TestNoDeadEndPairing: ) result = await flow.async_step_adb_pair( - {CONF_ADB_HOST: "10.0.0.77", CONF_ADB_PORT: 5555} + {CONF_HOST: "10.0.0.77", CONF_ADB_PORT: 5555} ) assert result["type"] == "create_entry" - assert result["options"][CONF_ADB_HOST] == "10.0.0.77" + assert result["options"][CONF_HOST] == "10.0.0.77" assert test_connect.await_args.args[0] == "10.0.0.77" async def test_protocol_form_host_correction_restarts_pairing(self, tmp_path, monkeypatch): @@ -801,7 +795,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -814,12 +808,12 @@ class TestNoDeadEndPairing: monkeypatch.setattr( ShieldConnectionManager, "build_protocol_client", lambda *a, **k: second ) - form = await flow.async_step_protocol_pair({CONF_PROTOCOL_HOST: "10.0.0.77"}) + form = await flow.async_step_protocol_pair({CONF_HOST: "10.0.0.77"}) assert form["step_id"] == "protocol_pair" assert first.disconnects == 1 assert second.pairing_started is True - assert flow._user_data[CONF_PROTOCOL_HOST] == "10.0.0.77" + assert flow._user_data[CONF_HOST] == "10.0.0.77" async def test_adb_no_host_error_keeps_the_user_on_a_fixable_form( self, tmp_path, monkeypatch @@ -834,7 +828,7 @@ class TestNoDeadEndPairing: result = await flow.async_step_adb_pair({CONF_ADB_PORT: 5555}) assert result["errors"] == {"base": "adb_no_host"} - assert CONF_ADB_HOST in vol_mirror.declared_fields(result["data_schema"]) + assert CONF_HOST in vol_mirror.declared_fields(result["data_schema"]) async def test_adb_failed_error_keeps_the_user_on_the_form(self, tmp_path, monkeypatch): module = _form_module() @@ -844,7 +838,7 @@ class TestNoDeadEndPairing: _user_input( **{ CONF_ADB_ENABLED: True, - CONF_ADB_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } ) ) @@ -873,7 +867,7 @@ class TestNoDeadEndPairing: flow._user_data = { "name": "mdarcy", CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: DISCOVERED_HOST, + CONF_HOST: DISCOVERED_HOST, } result = await flow.async_step_protocol_pair(None) @@ -890,7 +884,7 @@ class TestNoDeadEndPairing: entry_id="e1", title="mdarcy", data={}, - options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: DISCOVERED_HOST}, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: DISCOVERED_HOST}, ) hass.config_entries.by_id["e1"] = entry flow.context = {"entry_id": "e1"} @@ -912,9 +906,8 @@ class TestProtocolReconfigure: def _entry(self, **overrides): options = { CONF_PROTOCOL_ENABLED: False, # the "continue without pairing" outcome - CONF_PROTOCOL_HOST: "", CONF_ADB_ENABLED: True, - CONF_ADB_HOST: "10.0.0.9", + CONF_HOST: "10.0.0.9", # t41: the entry's one address CONF_ADB_PORT: 5555, } options.update(overrides) @@ -944,7 +937,7 @@ class TestProtocolReconfigure: assert form["step_id"] == "protocol_pair" # the PIN step assert client.pairing_started is True fields = vol_mirror.declared_fields(form["data_schema"]) - assert CONF_PROTOCOL_HOST in fields # the address is editable here + assert CONF_HOST in fields # the address is editable here assert CONF_PAIRING_CODE in fields result = await flow.async_step_protocol_pair( @@ -954,10 +947,10 @@ class TestProtocolReconfigure: assert result["reason"] == "reconfigure_successful" # the transport is ON and carries the address that just paired assert entry.options[CONF_PROTOCOL_ENABLED] is True - assert entry.options[CONF_PROTOCOL_HOST] == "10.0.0.9" + assert entry.options[CONF_HOST] == "10.0.0.9" # the rest of the entry's options survive the update assert entry.options[CONF_ADB_ENABLED] is True - assert entry.options[CONF_ADB_HOST] == "10.0.0.9" + assert entry.options[CONF_HOST] == "10.0.0.9" assert entry.unique_id == FAKE_MAC assert hass.config_entries.reloaded_entry_ids == ["e1"] assert client.finished_codes == [FAKE_PAIRING_CODE] @@ -990,7 +983,7 @@ class TestProtocolReconfigure: ShieldConnectionManager, "build_protocol_client", lambda *a, **k: second ) form = await flow.async_step_protocol_pair( - {CONF_PROTOCOL_HOST: "10.0.0.77", CONF_PAIRING_CODE: "0000"} + {CONF_HOST: "10.0.0.77", CONF_PAIRING_CODE: "0000"} ) assert form["step_id"] == "protocol_pair" # asks for the NEW code @@ -1003,7 +996,7 @@ class TestProtocolReconfigure: ) assert result["reason"] == "reconfigure_successful" - assert entry.options[CONF_PROTOCOL_HOST] == "10.0.0.77" + assert entry.options[CONF_HOST] == "10.0.0.77" async def test_reconfigure_skip_leaves_the_entry_untouched( self, tmp_path, monkeypatch diff --git a/tests/test_init.py b/tests/test_init.py index 8aed762..ea4e34a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -119,8 +119,9 @@ class TestYamlSeedBlocks: """_yaml_seed_blocks: the optional domain key → normalized seeds.""" def test_block_normalized(self, _integration_module): - from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT + from nsh.const import CONF_ADB_PORT, CONF_HOST, DEFAULT_ADB_PORT + # t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST blocks = _integration_module._yaml_seed_blocks( [{"name": "Living", "adb_host": "10.0.0.31"}] ) @@ -128,20 +129,31 @@ class TestYamlSeedBlocks: assert len(blocks) == 1 seed = blocks[0] assert seed["name"] == "Living" - assert seed[CONF_ADB_HOST] == "10.0.0.31" + assert seed[CONF_HOST] == "10.0.0.31" assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT assert seed["adb_enabled"] is True assert "unique_id" not in seed # optional key only + def test_the_current_host_key_seeds_too(self, _integration_module): + from nsh.const import CONF_HOST + + seed = _integration_module._yaml_seed_blocks( + [{"name": "Desk", CONF_HOST: "shield.local"}] + )[0] + + assert seed[CONF_HOST] == "shield.local" + assert seed["adb_enabled"] is True # an address names the Shield + def test_single_block_and_protocol_hint(self, _integration_module): blocks = _integration_module._yaml_seed_blocks( {"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000} ) assert len(blocks) == 1 - # protocol fields are prefill hints ONLY (pairing stays interactive) - assert blocks[0]["protocol_host"] == "shield.local" + # a legacy protocol-only block still seeds, with ADB left OFF (t32 F4) + assert blocks[0]["host"] == "shield.local" assert blocks[0]["adb_port"] == 5000 + assert blocks[0]["adb_enabled"] is False def test_list_and_single_block_shapes(self, _integration_module): single = _integration_module._yaml_seed_blocks({"name": "A"}) @@ -166,7 +178,23 @@ class TestSeeding: domain, context, data = hass.config_entries.flows[0] assert domain == "nvidia_shield" assert context["source"] == "import" - assert data["adb_host"] == "10.0.0.5" + assert data["host"] == "10.0.0.5" # t41: one address field + + +class TestSeedingCurrentKey: + """The t41 `host` key seeds exactly like the legacy one.""" + + async def test_host_key_seeds_a_flow(self, _integration_module): + hass = _SeedHass(entries=[]) + + await _integration_module.async_setup( + hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]} + ) + if hass.scheduled_tasks: + await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True) + + assert len(hass.config_entries.flows) == 1 + assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7" async def test_hostless_block_never_seeds(self, _integration_module): hass = _SeedHass(entries=[]) @@ -229,12 +257,12 @@ class TestImportStep: return flow async def test_ui_configured_host_never_re_seeded(self): - from nsh.const import CONF_ADB_HOST + from nsh.const import CONF_HOST existing = SimpleNamespace( entry_id="ui1", unique_id=None, - options={CONF_ADB_HOST: "10.0.0.31"}, + options={CONF_HOST: "10.0.0.31"}, ) # options live on the entry (conf zeroconf guard reads them) flow = self._flow([existing]) diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 45d6793..8596f90 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -21,11 +21,10 @@ import voluptuous_mirror as vol_mirror from conftest import _SHIELD_DIR, _load_module from nsh.const import ( CONF_ADB_ENABLED, - CONF_ADB_HOST, CONF_ADB_PORT, CONF_ANDROIDTV_ENTITY, + CONF_HOST, CONF_PROTOCOL_ENABLED, - CONF_PROTOCOL_HOST, ) _STUB_NAMES = ( @@ -302,7 +301,7 @@ def _user_input(**overrides) -> dict: base = { "name": "mdarcy", CONF_PROTOCOL_ENABLED: False, - CONF_PROTOCOL_HOST: "", + CONF_HOST: "", CONF_ADB_ENABLED: False, } base.update(overrides) @@ -372,7 +371,7 @@ class TestStandaloneValidation: flow._user_data = { "name": "mdarcy", CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: "shield.local", + CONF_HOST: "shield.local", } # skip the pairing route: drive _create_entry directly @@ -436,7 +435,7 @@ class TestMigrationKeepsOrAllowsAbsence: ( entry, { - "options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""}, + "options": {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}, "version": 2, }, ) @@ -449,7 +448,7 @@ class TestMigrationKeepsOrAllowsAbsence: entry = SimpleNamespace( version=2, data={}, - options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"}, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"}, title="mdarcy", ) @@ -791,10 +790,10 @@ class TestIpChangeSelfHealF1: return SimpleNamespace( entry_id="e1", title="Shield", - data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + data={}, options={ CONF_ADB_ENABLED: True, - CONF_ADB_HOST: host, + CONF_HOST: host, # t41: the entry's one address CONF_ADB_PORT: 5555, }, runtime_data=None, @@ -831,11 +830,7 @@ class TestIpChangeSelfHealF1: # the config-flow zeroconf path updates the entry's hosts via # async_update_entry — the NEXT built poller targets the NEW address - entry.options = { - **entry.options, - CONF_ADB_HOST: "192.168.1.77", - CONF_PROTOCOL_HOST: "192.168.1.77", - } + entry.options = {**entry.options, CONF_HOST: "192.168.1.77"} second = factory() assert second is not None @@ -890,7 +885,7 @@ class TestMacLessDiscoveryF2: entry = SimpleNamespace( entry_id="e1", unique_id=None, # MAC-less entry - options={CONF_PROTOCOL_HOST: "192.168.1.50"}, + options={CONF_HOST: "192.168.1.50"}, title="Shield", ) self._holder().entries[:] = [entry] @@ -951,7 +946,7 @@ class TestSeedBlockShapeF4: ) assert blocks[0]["adb_enabled"] is False - assert blocks[0]["adb_host"] == "" + assert blocks[0]["host"] == "shield.local" # t41: one address field def test_adb_block_keeps_adb_on(self): import conftest @@ -1006,8 +1001,7 @@ class TestZeroconfDiscovery: assert result["step_id"] == "user" assert flow._discovered_host == "192.168.1.50" defaults = vol_mirror.defaults_by_key(result["data_schema"]) - assert defaults[CONF_ADB_HOST] == "192.168.1.50" - assert defaults[CONF_PROTOCOL_HOST] == "192.168.1.50" + assert defaults[CONF_HOST] == "192.168.1.50" # ...and a discovery-driven form says the address is already known note = result["description_placeholders"]["discovery_note"] assert "Discovered Shield at 192.168.1.50" in note @@ -1031,7 +1025,7 @@ class TestZeroconfDiscovery: entry = SimpleNamespace( unique_id="aa:bb:cc:dd:ee:ff", - options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"}, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.1"}, entry_id="e1", title="Shield", ) @@ -1042,9 +1036,8 @@ class TestZeroconfDiscovery: ) assert result == {"type": "abort", "reason": "already_configured"} - # the hosts moved to the discovery address - assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50" - assert entry.options["adb_host"] == "192.168.1.50" + # t41: the ONE address field moved to the discovery address + assert entry.options[CONF_HOST] == "192.168.1.50" assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a) async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path): @@ -1053,14 +1046,10 @@ class TestZeroconfDiscovery: hass = FakeHass(str(tmp_path)) flow.hass = hass - # both hosts already hold the discovered address: nothing changes + # the address already holds the discovered value: nothing changes entry = SimpleNamespace( unique_id="aa:bb:cc:dd:ee:ff", - options={ - CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: "192.168.1.50", - "adb_host": "192.168.1.50", - }, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"}, entry_id="e1", title="Shield", ) @@ -1074,8 +1063,24 @@ class TestZeroconfDiscovery: assert hass.config_entries.updating_entry_calls == [] # no IP change assert hass.config_entries.reloaded_entry_ids == [] - async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path): - """F2: a blank (auto-detect) adb host follows a same-IP rediscovery.""" + async def test_the_per_host_heal_cases_collapsed_into_one_rule(self): + """t41: the blank / self-heal / deliberate-override cases are gone. + + They only existed because the address was stored twice (t21 F2). With one + field the discovery address simply IS the entry's address, so this pins + the simplification: a second address field cannot quietly come back. + """ + source = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8") + + assert "CONF_ADB_HOST" not in source + assert "CONF_PROTOCOL_HOST" not in source + + async def test_an_explicit_address_follows_the_device(self, tmp_path): + """t41: one Shield, one address — discovery is its current source. + + The old rule kept a "deliberately different ADB host" untouched; that + override is precisely what the single-field ruling removes. + """ module = _form_module() flow = module.NvidiaShieldConfigFlow() hass = FakeHass(str(tmp_path)) @@ -1083,37 +1088,7 @@ class TestZeroconfDiscovery: entry = SimpleNamespace( unique_id="aa:bb:cc:dd:ee:ff", - options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"}, - entry_id="e1", - title="Shield", - ) - sys.modules[__name__]._zc_holder.entries[:] = [entry] - - result = await flow.async_step_zeroconf( - self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"}) - ) - - assert result == {"type": "abort", "reason": "already_configured"} - # only the adb host was filled (self-heal), protocol already current: - update = hass.config_entries.updating_entry_calls[0][1]["options"] - assert update["adb_host"] == "192.168.1.50" - assert update["protocol_host"] == "192.168.1.50" - assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a) - - async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path): - """F2: an explicit ADB override survives a protocol-host IP change.""" - module = _form_module() - flow = module.NvidiaShieldConfigFlow() - hass = FakeHass(str(tmp_path)) - flow.hass = hass - - entry = SimpleNamespace( - unique_id="aa:bb:cc:dd:ee:ff", - options={ - CONF_PROTOCOL_ENABLED: True, - CONF_PROTOCOL_HOST: "10.0.0.1", # stale (IP moved) - "adb_host": "10.0.0.9", # deliberate ADB override - }, + options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale entry_id="e1", title="Shield", ) @@ -1125,8 +1100,7 @@ class TestZeroconfDiscovery: assert result == {"type": "abort", "reason": "already_configured"} update = hass.config_entries.updating_entry_calls[0][1]["options"] - assert update["protocol_host"] == "192.168.1.50" # protocol followed - assert update["adb_host"] == "10.0.0.9" # override untouched + assert update[CONF_HOST] == "192.168.1.50" assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)