fix(poller): F1 — the poller factory re-reads entry.options at call time (audit-sanctioned option), so the next built poller targets the NEW address after a zeroconf IP change; the stale live-read comment in config_flow points at the actual mechanism; TestIpChangeSelfHealF1 drives an IP change and pins the next poller host (t32)

This commit is contained in:
James Mackay committed 2026-10-09 08:03:28 +01:00
1 parent c1a82a2fa5
commit 8f40b6435c
3 files changed
+91 -2

No files matched your search

+12 -1
View File
@@ -130,7 +130,18 @@ async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
) )
return None 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -363,7 +363,8 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if options != entry.options: if options != entry.options:
# t29 G3a: the deprecated update+schedule-reload combo is # t29 G3a: the deprecated update+schedule-reload combo is
# gone (2026.12 error) — the entry updates ONLY here; the # 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( self.hass.config_entries.async_update_entry(
entry, options=options entry, options=options
) )
+77
View File
@@ -10,11 +10,16 @@ migration validity for v2 entries without the field.
import asyncio # noqa: F401 (async steps need the event loop) import asyncio # noqa: F401 (async steps need the event loop)
import sys import sys
import types import types
from pathlib import Path # noqa: E402 (F1 test key staging)
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock
import pytest import pytest
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
from nsh.const import ( from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST, CONF_PROTOCOL_HOST,
@@ -758,6 +763,78 @@ class TestLegacyUniqueIdMigration:
assert hass.config_entries.updating_entry_calls == [] # untouched 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: class TestZeroconfDiscovery:
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step.""" """P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""