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:
1 parent
75ebeb40b0
commit
81f70585cd
5 files changed
+187
-46
No files matched your search
@@ -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 = {}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user