feat(config_flow): standalone setup — androidtv entity optional with no_state_source validation, entry data omits the field for standalone entries, sensor tolerates its absence; migration keeps v1 field / v2 absence valid (P3-1a,d)
This commit is contained in:
1 parent
a65c893339
commit
d35b61078d
5 files changed
+359
-32
No files matched your search
@@ -75,6 +75,30 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_schema_dict(sel) -> dict:
|
||||||
|
"""User-step schema — the androidtv entity is optional (standalone)."""
|
||||||
|
schema_dict = {
|
||||||
|
vol.Required("name", default=DEFAULT_NAME): str,
|
||||||
|
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
||||||
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||||
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there are any receivers with DAG input maps, show a text field
|
||||||
|
# for receiver input (will be a dropdown in options once receiver is set)
|
||||||
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||||
|
return schema_dict
|
||||||
|
|
||||||
|
|
||||||
def _build_selectors():
|
def _build_selectors():
|
||||||
"""Build reusable selector instances."""
|
"""Build reusable selector instances."""
|
||||||
return {
|
return {
|
||||||
@@ -108,16 +132,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY]
|
# Standalone setup (Phase 3): the external androidtv media_player
|
||||||
|
# is optional — the protocol push can be the only state source.
|
||||||
|
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
|
||||||
await self.async_set_unique_id(androidtv_entity)
|
if not androidtv_entity and not user_input.get(CONF_PROTOCOL_ENABLED):
|
||||||
self._abort_if_unique_id_configured()
|
errors["base"] = "no_state_source"
|
||||||
|
sel = _build_selectors()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(_user_schema_dict(sel)),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
if androidtv_entity:
|
||||||
|
await self.async_set_unique_id(androidtv_entity)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
# Auto-detect Jellyfin if not manually specified
|
# Auto-detect Jellyfin if not manually specified
|
||||||
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||||
if not jellyfin:
|
if not jellyfin:
|
||||||
device_name = user_input.get("name", DEFAULT_NAME)
|
device_name = user_input.get("name", DEFAULT_NAME)
|
||||||
atv_state = self.hass.states.get(androidtv_entity)
|
atv_state = (
|
||||||
|
self.hass.states.get(androidtv_entity)
|
||||||
|
if androidtv_entity
|
||||||
|
else None
|
||||||
|
)
|
||||||
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
||||||
|
|
||||||
for name in [device_name, atv_name]:
|
for name in [device_name, atv_name]:
|
||||||
@@ -131,7 +171,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": user_input.get("name", DEFAULT_NAME),
|
"name": user_input.get("name", DEFAULT_NAME),
|
||||||
CONF_ANDROIDTV_ENTITY: androidtv_entity,
|
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
||||||
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||||
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||||
@@ -156,25 +196,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
|
|
||||||
schema_dict = {
|
schema_dict = _user_schema_dict(sel)
|
||||||
vol.Required("name", default=DEFAULT_NAME): str,
|
|
||||||
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
|
||||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
|
||||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# If there are any receivers with DAG input maps, show a text field
|
|
||||||
# for receiver input (will be a dropdown in options once receiver is set)
|
|
||||||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@@ -185,9 +207,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
def _create_entry(self):
|
def _create_entry(self):
|
||||||
"""Create the config entry from stored user data."""
|
"""Create the config entry from stored user data."""
|
||||||
d = self._user_data
|
d = self._user_data
|
||||||
|
entry_data = {}
|
||||||
|
if d.get(CONF_ANDROIDTV_ENTITY):
|
||||||
|
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=d["name"],
|
title=d["name"],
|
||||||
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]},
|
data=entry_data,
|
||||||
options={
|
options={
|
||||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
super().__init__(hass, entry)
|
super().__init__(hass, entry)
|
||||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
def _manager(self) -> ShieldConnectionManager | None:
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Device Name",
|
||||||
"androidtv_entity": "Android TV Remote",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver Input",
|
||||||
@@ -53,7 +53,8 @@
|
|||||||
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
||||||
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again."
|
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||||
|
"no_state_source": "No state source selected. Link the Android TV Remote entity or enable the Nvidia Remote Protocol pairing \u2014 the integration needs at least one source of power/app state."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield entity is already configured.",
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "Which input on the receiver.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Device Name",
|
||||||
"androidtv_entity": "Android TV Remote",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver Input",
|
||||||
@@ -53,7 +53,8 @@
|
|||||||
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
||||||
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again."
|
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||||
|
"no_state_source": "No state source selected. Link the Android TV Remote entity or enable the Nvidia Remote Protocol pairing \u2014 the integration needs at least one source of power/app state."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield entity is already configured.",
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "Which input on the receiver.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""P3-1 tests: standalone setup — optional androidtv entity + app fallback.
|
||||||
|
|
||||||
|
The integration no longer hard-requires an external androidtv media_player:
|
||||||
|
the protocol current-app push can be the only state source. Covers the flow
|
||||||
|
validation (no state source → form error), the standalone pairing route,
|
||||||
|
entry data omitting the field, media_player app_id/app_name fallback, and
|
||||||
|
migration validity for v2 entries without the field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio # noqa: F401 (async steps need the event loop)
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.const",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
"nsh.entry",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_stubs() -> None:
|
||||||
|
"""Import-time stand-ins (idempotent; same shape as the pairing tests)."""
|
||||||
|
if sys.modules.get("voluptuous") is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
vol = types.ModuleType("voluptuous")
|
||||||
|
vol.Required = lambda key, default=None: key
|
||||||
|
vol.Optional = lambda key, default=None: key
|
||||||
|
vol.Schema = lambda schema: schema
|
||||||
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
|
ha = types.ModuleType("homeassistant")
|
||||||
|
sys.modules["homeassistant"] = ha
|
||||||
|
|
||||||
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
|
|
||||||
|
class ConfigFlow:
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init_subclass__(cls, **kwargs):
|
||||||
|
kwargs.pop("domain", None)
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
|
return {"type": "form", "step_id": step_id, "errors": dict(errors or {})}
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data,
|
||||||
|
"options": options}
|
||||||
|
|
||||||
|
def async_abort(self, *, reason):
|
||||||
|
return {"type": "abort", "reason": reason}
|
||||||
|
|
||||||
|
async def async_set_unique_id(self, unique_id):
|
||||||
|
self._unique_id = unique_id
|
||||||
|
|
||||||
|
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class OptionsFlow:
|
||||||
|
pass
|
||||||
|
|
||||||
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
|
||||||
|
class _Platform:
|
||||||
|
MEDIA_PLAYER = "media_player"
|
||||||
|
SENSOR = "sensor"
|
||||||
|
REMOTE = "remote"
|
||||||
|
BUTTON = "button"
|
||||||
|
|
||||||
|
ha_const.Platform = _Platform
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
entity_registry.async_get = lambda hass: SimpleNamespace(entities={})
|
||||||
|
helpers.entity_registry = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfigEntries:
|
||||||
|
def __init__(self):
|
||||||
|
self.updating_entry_calls: list = []
|
||||||
|
|
||||||
|
def async_get_entry(self, entry_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def async_update_entry(self, entry, **kwargs):
|
||||||
|
self.updating_entry_calls.append((entry, kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHass:
|
||||||
|
def __init__(self, tmp_dir: str, states: dict | None = None):
|
||||||
|
self.config = SimpleNamespace(config_dir=tmp_dir)
|
||||||
|
self.config_entries = FakeConfigEntries()
|
||||||
|
self._states = states or {}
|
||||||
|
|
||||||
|
class _States:
|
||||||
|
def get(self, entity_id):
|
||||||
|
return self._states.get(entity_id)
|
||||||
|
|
||||||
|
self._states_ref = _States()
|
||||||
|
self._states_ref._states = self._states
|
||||||
|
self.states = self._states_ref
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def async_add_executor_job(fn, *args):
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_stubs():
|
||||||
|
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
def _form_module():
|
||||||
|
_install_stubs()
|
||||||
|
if "nsh.helpers" not in sys.modules:
|
||||||
|
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
||||||
|
if "nsh.config_flow" not in sys.modules:
|
||||||
|
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
||||||
|
return sys.modules["nsh.config_flow"]
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_module():
|
||||||
|
_install_stubs()
|
||||||
|
if "nsh.entry" not in sys.modules:
|
||||||
|
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
|
||||||
|
return sys.modules["nsh.entry"]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_input(**overrides) -> dict:
|
||||||
|
base = {
|
||||||
|
"name": "mdarcy",
|
||||||
|
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
|
CONF_PROTOCOL_HOST: "",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandaloneValidation:
|
||||||
|
async def test_neither_source_shows_form_error(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
|
|
||||||
|
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(_user_input())
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
||||||
|
|
||||||
|
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow._user_data = {}
|
||||||
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
async def async_generate_cert_if_missing(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_start_pairing(self):
|
||||||
|
return "8421"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ShieldConnectionManager, "build_protocol_client",
|
||||||
|
lambda *a, **k: _Client(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module, "protocol_cert_paths", lambda hass: ("/tmp/a.pem", "/tmp/a.key")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||||
|
|
||||||
|
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
||||||
|
"""No androidtv entity → the key is absent from entry data (not "")."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow._user_data = {
|
||||||
|
"name": "mdarcy",
|
||||||
|
CONF_ANDROIDTV_ENTITY: "",
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "shield.local",
|
||||||
|
}
|
||||||
|
|
||||||
|
# skip the pairing route: drive _create_entry directly
|
||||||
|
result = flow._create_entry()
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrationKeepsOrAllowsAbsence:
|
||||||
|
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=1,
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
|
||||||
|
# options were rewritten with the protocol defaults; the data dict —
|
||||||
|
# including the androidtv field — was never touched
|
||||||
|
assert hass.config_entries.updating_entry_calls == [
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
{
|
||||||
|
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
|
||||||
|
"version": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_v2_without_the_field_is_valid(self, tmp_path):
|
||||||
|
"""Standalone entries born v2 must pass through migration untouched."""
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=2,
|
||||||
|
data={},
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
|
||||||
Reference in new issue
Block a user