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:
James Mackay committed 2026-10-08 10:28:56 +01:00
1 parent a65c893339
commit d35b61078d
5 files changed
+359 -32

No files matched your search

+50 -25
View File
@@ -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, ""),
+1 -1
View File
@@ -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:
+4 -3
View File
@@ -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.",
+300
View File
@@ -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