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

+2 -4
View File
@@ -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: "",
+7 -10
View File
@@ -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})
]
+52 -59
View File
@@ -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
+35 -7
View File
@@ -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])
+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)