feat(config)!: migrate entries to v3 — one address, no androidtv key (t41)

ENTRY_VERSION = 3, declared once in const.py and reused as the flow's VERSION so
the stored shape and the migration cannot drift.

v2 → v3 mirrors the two shape changes:
* the duplicated address fields collapse into CONF_HOST — the protocol host
  wins, else the ADB host (both name the same Shield), reading options first and
  data second so a hand-written seed is covered too;
* the androidtv-entity key is dropped from data AND options with a logged note
  naming the dropped entity (the old value would only mislead now that nothing
  reads it).

A v1 entry still travels the whole way in one call (the v1→v2 protocol defaults
are applied, then the v3 collapse). Downgrades (version > 3) are rejected so HA
can offer a rollback, and the current version is a no-op.

Existing entries carrying the entity key keep working until the migration runs:
nothing reads it anymore (the c1 commit), which is exactly why the migration can
be a plain removal.

Test note: my first version of the version-parity pin called a bare
entity_stublib.install() and leaked the shallow stub set into the entity-module
loader of later tests (STATE_PAUSED/entity_available import failures). It now
uses the file's scoped harness, like every other test there.

Verified: 316 passed / 2 skipped, 317 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (36 call arguments — one more awaited/checked call).
This commit is contained in:
James Mackay committed 2026-10-11 06:19:36 +01:00
1 parent 75ebeb40b0
commit 81f70585cd
5 files changed
+187 -46

No files matched your search

+52 -13
View File
@@ -24,11 +24,14 @@ from .const import (
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST, CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_HOST, CONF_HOST,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
) )
from .helpers import ( from .helpers import (
@@ -206,26 +209,62 @@ async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate v1 entries to v2 (adds the remote-protocol fields). """Migrate an older config entry to the current version.
ADB options carry over untouched; the ADB-host discovery fallback keeps v1 → v2 added the remote-protocol fields (ADB options carried over
serving as the protocol host until pairing populates it. Downgrades untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
(version > 2) are rejected so HA offers a rollback.
* the duplicated address fields collapse into the single CONF_HOST — the
protocol host wins, else the ADB host, since both name the same Shield;
* the androidtv-entity key goes. This integration owns the whole stack now:
the entity it used to configure is neither a state source nor an app-name
or power tier, so keeping the value would only mislead.
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
""" """
if config_entry.version > 2: if config_entry.version > ENTRY_VERSION:
return False return False
if config_entry.version >= ENTRY_VERSION:
return True
options = {**config_entry.options}
data = {**config_entry.data}
if config_entry.version < 2: if config_entry.version < 2:
options = {**config_entry.options}
options.setdefault(CONF_PROTOCOL_ENABLED, False) options.setdefault(CONF_PROTOCOL_ENABLED, False)
# 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):
if not options.get(CONF_HOST): options[CONF_HOST] = (
options[CONF_HOST] = options.get(CONF_ADB_HOST) or "" options.get(CONF_PROTOCOL_HOST)
hass.config_entries.async_update_entry( or options.get(CONF_ADB_HOST)
config_entry, options=options, version=2 or data.get(CONF_PROTOCOL_HOST)
or data.get(CONF_ADB_HOST)
or ""
) )
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
options.pop(legacy_key, None)
data.pop(legacy_key, None)
hass.config_entries.async_update_entry(
config_entry, data=data, options=options, version=ENTRY_VERSION
)
if dropped_entity:
_LOGGER.info( _LOGGER.info(
"Migrated %s config entry to version 2", config_entry.title "%s: config entry migrated to version %d — dropped the androidtv "
"entity %s (this integration reads the Shield directly; the entry "
"needs no external media_player)",
config_entry.title,
ENTRY_VERSION,
dropped_entity,
)
else:
_LOGGER.info(
"Migrated %s config entry to version %d",
config_entry.title,
ENTRY_VERSION,
) )
return True return True
@@ -33,6 +33,7 @@ from .const import (
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
) )
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
@@ -190,7 +191,7 @@ def _build_selectors():
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield.""" """Handle a config flow for Nvidia Shield."""
VERSION = 2 VERSION = ENTRY_VERSION
def __init__(self): def __init__(self):
self._user_data = {} self._user_data = {}
+5
View File
@@ -55,6 +55,11 @@ STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
DEFAULT_NAME = "Nvidia Shield" DEFAULT_NAME = "Nvidia Shield"
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
# androidtv-entity key. One constant so the flow and the migration cannot drift.
ENTRY_VERSION = 3
# Emotiva DAG attribute we read to get available inputs # Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map" ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
+82 -12
View File
@@ -15,6 +15,7 @@ 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_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_HOST, CONF_HOST,
@@ -22,6 +23,7 @@ from nsh.const import (
CONF_PAIRING_CODE, CONF_PAIRING_CODE,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
@@ -392,12 +394,25 @@ class TestReauthFlow:
class TestEntryMigration: class TestEntryMigration:
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch): """t41: v2 → v3 collapses the two address fields into CONF_HOST.
The protocol host wins, else the ADB host (both name the same Shield); the
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
travels the whole way in one call.
"""
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=1, version=1,
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"}, data={},
options={
"adb_enabled": True,
"adb_port": 5555,
"jellyfin_entity": "jf.x",
"adb_host": "10.0.0.31", # legacy address
},
title="mdarcy", title="mdarcy",
) )
@@ -407,27 +422,82 @@ class TestEntryMigration:
# ADB options carried over byte-compatibly # ADB options carried over byte-compatibly
assert entry.options["adb_enabled"] is True assert entry.options["adb_enabled"] is True
assert entry.options["adb_port"] == 5555 assert entry.options["adb_port"] == 5555
# Protocol fields default; the entry keeps its own address (t41: the v1 assert entry.options["jellyfin_entity"] == "jf.x"
# adb_host is mapped onto CONF_HOST, empty here) # the v2 default, then the v3 address collapse
assert entry.options[CONF_PROTOCOL_ENABLED] is False assert entry.options[CONF_PROTOCOL_ENABLED] is False
assert entry.options[CONF_HOST] == "" assert entry.options[CONF_HOST] == "10.0.0.31"
assert hass.config_entries.updating_entry_calls == [ assert "adb_host" not in entry.options
(entry, {"options": entry.options, "version": 2}) assert entry.version == 3
]
async def test_v2_is_a_noop(self, tmp_path, monkeypatch): async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=2, version=2,
options={CONF_PROTOCOL_ENABLED: True}, data={},
options={
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
CONF_ADB_HOST: "10.0.0.1",
},
title="mdarcy", title="mdarcy",
) )
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.2"
assert CONF_PROTOCOL_HOST not in entry.options
assert CONF_ADB_HOST not in entry.options
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
assert entry.version == 3
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.1"
assert CONF_ADB_HOST not in entry.options
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == ""
assert CONF_ADB_HOST not in entry.options
assert CONF_PROTOCOL_HOST not in entry.options
assert entry.version == 3
async def test_the_current_version_is_a_noop(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=3,
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True assert await module.async_migrate_entry(hass, entry) is True
assert hass.config_entries.updating_entry_calls == [] assert hass.config_entries.updating_entry_calls == []
async def test_downgrade_rejected(self, tmp_path, monkeypatch): async def test_downgrade_rejected(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=3, options={}, title="x") entry = SimpleNamespace(version=4, options={}, title="x")
assert await module.async_migrate_entry(hass, entry) is False assert await module.async_migrate_entry(hass, entry) is False
+46 -20
View File
@@ -9,6 +9,7 @@ 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 gc import gc
import logging
import sys import sys
import types import types
import warnings import warnings
@@ -414,48 +415,73 @@ class TestAdbOnlyStandalone:
assert result["errors"] == {"base": "no_state_source"} assert result["errors"] == {"base": "no_state_source"}
class TestMigrationKeepsOrAllowsAbsence: class TestMigrationDropsTheEntityKey:
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path): """t41: the androidtv key is dropped from BOTH dicts with a logged note.
An entry that still carries it keeps working until then (nothing reads it) —
the migration is what removes the last trace of the entity path.
"""
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=1, version=1,
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
options={}, options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
title="mdarcy", title="mdarcy",
) )
ok = await module.async_migrate_entry(hass, entry) with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True assert ok is True
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept assert CONF_ANDROIDTV_ENTITY not in entry.data
# options were rewritten with the protocol defaults; the data dict — assert CONF_ANDROIDTV_ENTITY not in entry.options
# including the androidtv field — was never touched assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
assert hass.config_entries.updating_entry_calls == [ assert entry.data == {}
( # the note names what was dropped, so the log is actionable
entry, notes = [record.getMessage() for record in caplog.records]
{ assert any("media_player.shield" in note for note in notes), notes
"options": {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}, assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
"version": 2,
},
)
]
async def test_v2_without_the_field_is_valid(self, tmp_path): async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
"""Standalone entries born v2 must pass through migration untouched.""" module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
notes = [record.getMessage() for record in caplog.records]
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
"""An entry written before the unification lands on CONF_HOST."""
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=2, version=2,
data={}, data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"}, options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
title="mdarcy", title="mdarcy",
) )
ok = await module.async_migrate_entry(hass, entry) ok = await module.async_migrate_entry(hass, entry)
assert ok is True assert ok is True
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed assert entry.options[CONF_HOST] == "shield.local"
assert "protocol_host" not in entry.options
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
# _form_module owns the scoped stub install/pop — never a bare
# entity_stublib.install() here: a shallow stub set leaks into the
# entity-module loader of later tests.
module = _form_module()
from nsh.const import ENTRY_VERSION
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
def _cmd_player(modules): def _cmd_player(modules):