diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py index b830403..469e84a 100644 --- a/custom_components/nvidia_shield/__init__.py +++ b/custom_components/nvidia_shield/__init__.py @@ -130,7 +130,18 @@ async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry): ) return None - return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path) + # t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the + # host/port resolve from entry.options AT CALL TIME, so the poller built + # after a zeroconf IP change targets the NEW address without a reload + # (the setup-time closure pinned the old address). A key-path change + # still needs the Pair ADB button / reconfigure reload, by design. + def _factory(): + adb_now = resolve_adb_config(hass, entry) + if adb_now is None: + return None + return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path) + + return _factory async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py index 637aafb..4e5cf8d 100644 --- a/custom_components/nvidia_shield/config_flow.py +++ b/custom_components/nvidia_shield/config_flow.py @@ -363,7 +363,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if options != entry.options: # t29 G3a: the deprecated update+schedule-reload combo is # gone (2026.12 error) — the entry updates ONLY here; the - # manager reads the host options live on the next cycle. + # self-heal rides on the t32 F1 poller factory, which + # re-reads entry.options at call time (no reload). self.hass.config_entries.async_update_entry( entry, options=options ) diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index 1609e66..8bda98e 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -10,11 +10,16 @@ migration validity for v2 entries without the field. import asyncio # noqa: F401 (async steps need the event loop) import sys import types +from pathlib import Path # noqa: E402 (F1 test key staging) from types import SimpleNamespace +from unittest import mock import pytest from conftest import _SHIELD_DIR, _load_module from nsh.const import ( + CONF_ADB_ENABLED, + CONF_ADB_HOST, + CONF_ADB_PORT, CONF_ANDROIDTV_ENTITY, CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_HOST, @@ -758,6 +763,78 @@ class TestLegacyUniqueIdMigration: assert hass.config_entries.updating_entry_calls == [] # untouched +class TestIpChangeSelfHealF1: + """t32 F1 (HIGH): the poller factory re-reads entry.options at call + time, so the NEXT built poller targets the NEW address after a + zeroconf IP change (the audit-sanctioned factory re-read option).""" + + def _adb_entry(self, host="192.168.1.50"): + return SimpleNamespace( + entry_id="e1", + title="Shield", + data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + options={ + CONF_ADB_ENABLED: True, + CONF_ADB_HOST: host, + CONF_ADB_PORT: 5555, + }, + runtime_data=None, + ) + + def _hass_with_key(self, tmp_path): + import conftest + import entity_stublib + + entity_stublib.install() + if "nsh.init" not in sys.modules: + conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py") + init_module = sys.modules["nsh.init"] + hass = FakeHass(str(tmp_path)) + key_dir = Path(hass.config.config_dir) / ".storage" / "nvidia_shield" + key_dir.mkdir(parents=True, exist_ok=True) + (key_dir / "adbkey").write_text("test-key") + return init_module, hass + + async def test_built_poller_targets_the_new_address(self, tmp_path): + """The delivered self-heal: after the zeroconf host update the + NEXT built poller targets the new address (no reload involved).""" + init_module, hass = self._hass_with_key(tmp_path) + entry = self._adb_entry() + hass.config_entries.entries = [entry] + + factory = await init_module._build_poller_factory(hass, entry) + assert factory is not None + + first = factory() + assert first is not None + # AdbClientBase exposes the connection config as a dict (t27 F-NEW3) + assert first.config["host"] == "192.168.1.50" + + # 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", + } + + second = factory() + assert second is not None + assert second.config["host"] == "192.168.1.77" + + async def test_unresolvable_call_stays_passive(self, tmp_path): + """A momentary unresolvable host yields None (passive cycle) — the + watchdog's existing None handling stays intact.""" + init_module, hass = self._hass_with_key(tmp_path) + entry = self._adb_entry(host="10.255.255.1") + + factory = await init_module._build_poller_factory(hass, entry) + assert factory is not None + + with mock.patch("nsh.init.resolve_adb_config", return_value=None): + assert factory() is None + + class TestZeroconfDiscovery: """P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""