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

+36 -62
View File
@@ -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)