test(config_flow): zeroconf discovery (pre-fill, no-host abort, IP-change host update, same-IP skip) + apps map editor (store/parse/clear/prefill) + app_name map resolution tiers (P4-1)
This commit is contained in:
1 parent
61c7ac99a3
commit
f4d750ff92
3 files changed
+246
-13
No files matched your search
@@ -26,10 +26,10 @@
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
||||
"adb_port": "ADB port number. Default is 5555.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host)."
|
||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
@@ -54,12 +54,14 @@
|
||||
"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_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host)."
|
||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield entity is already configured.",
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_entry_missing": "This entry no longer exists."
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -75,7 +77,8 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"adb_port": "ADB Port",
|
||||
"app_name_map": "Apps name/icon map"
|
||||
},
|
||||
"data_description": {
|
||||
"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.",
|
||||
@@ -85,7 +88,8 @@
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"adb_port": "ADB port (default 5555).",
|
||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
||||
"adb_port": "ADB port number. Default is 5555.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host)."
|
||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
@@ -54,12 +54,14 @@
|
||||
"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_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host)."
|
||||
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield entity is already configured.",
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_entry_missing": "This entry no longer exists."
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -75,7 +77,8 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"adb_port": "ADB Port",
|
||||
"app_name_map": "Apps name/icon map"
|
||||
},
|
||||
"data_description": {
|
||||
"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.",
|
||||
@@ -85,7 +88,8 @@
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"adb_port": "ADB port (default 5555).",
|
||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,12 @@ def _install_stubs() -> 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 {})}
|
||||
return {
|
||||
"type": "form",
|
||||
"step_id": step_id,
|
||||
"errors": dict(errors or {}),
|
||||
"data_schema": data_schema,
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
@@ -74,7 +79,27 @@ def _install_stubs() -> None:
|
||||
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
||||
return None
|
||||
|
||||
def _async_current_entries(self):
|
||||
return _zc_holder.entries
|
||||
|
||||
class OptionsFlow:
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return {
|
||||
"type": "form",
|
||||
"step_id": step_id,
|
||||
"errors": dict(errors or {}),
|
||||
"data_schema": data_schema,
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data):
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
# Shared holder so tests can swap the current-entry list through the
|
||||
# module object (the stub closure reads this same object).
|
||||
_zc_holder = types.SimpleNamespace(entries=[])
|
||||
sys.modules[__name__]._zc_holder = _zc_holder
|
||||
|
||||
class AbortIfConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
@@ -118,12 +143,19 @@ def _install_stubs() -> None:
|
||||
class FakeConfigEntries:
|
||||
def __init__(self):
|
||||
self.updating_entry_calls: list = []
|
||||
self.reloaded_entry_ids: list[str] = []
|
||||
|
||||
def async_get_entry(self, entry_id):
|
||||
return None
|
||||
|
||||
def async_update_entry(self, entry, **kwargs):
|
||||
self.updating_entry_calls.append((entry, kwargs))
|
||||
for key, value in kwargs.items():
|
||||
if key in ("options", "data"):
|
||||
setattr(entry, key, value)
|
||||
|
||||
def async_schedule_reload(self, entry_id):
|
||||
self.reloaded_entry_ids.append(entry_id)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
@@ -332,3 +364,196 @@ class TestMigrationKeepsOrAllowsAbsence:
|
||||
|
||||
assert ok is True
|
||||
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
|
||||
|
||||
|
||||
def _cmd_player(modules):
|
||||
"""Standalone media_player entity via the command-routing harness."""
|
||||
import test_command_routing as routing
|
||||
|
||||
player, _served, _intents = routing.make_player(modules)
|
||||
return player, None, None
|
||||
|
||||
|
||||
class TestZeroconfDiscovery:
|
||||
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
||||
|
||||
def _discovery(self, host="192.168.1.50", props=None):
|
||||
return SimpleNamespace(
|
||||
ip_address=host, host=host, properties=props or {}
|
||||
)
|
||||
|
||||
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_zeroconf(self._discovery())
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
# pre-fill landed: the discovered host is recorded for the schema
|
||||
assert flow._discovered_host == "192.168.1.50"
|
||||
assert result["data_schema"] is not None
|
||||
|
||||
async def test_discovery_without_address_aborts(self, tmp_path):
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_zeroconf(
|
||||
SimpleNamespace(ip_address=None, host=None, properties={})
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "zeroconf_no_host"}
|
||||
|
||||
async def test_known_mac_ip_change_updates_entry_hosts(self, tmp_path):
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||
|
||||
result = await flow.async_step_zeroconf(
|
||||
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
# the hosts moved to the discovery address
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||
assert entry.options["adb_host"] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||
|
||||
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||
|
||||
result = await flow.async_step_zeroconf(
|
||||
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
||||
assert hass.config_entries.reloaded_entry_ids == []
|
||||
|
||||
|
||||
class TestAppsMapOptionsEditor:
|
||||
"""P4-1b: the apps name/icon map editor in the options flow."""
|
||||
|
||||
def _options_flow(self, tmp_path, options):
|
||||
module = _form_module()
|
||||
entry = SimpleNamespace(options=dict(options), title="Shield")
|
||||
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
return flow
|
||||
|
||||
async def test_map_text_stored_as_dict(self, tmp_path):
|
||||
flow = self._options_flow(tmp_path, options={})
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert flow._options["app_name_map"] == {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.plex.hdx": "Plex",
|
||||
}
|
||||
|
||||
async def test_invalid_map_shows_form_error(self, tmp_path):
|
||||
flow = self._options_flow(tmp_path, options={})
|
||||
|
||||
result = await flow.async_step_init({"app_name_map": "no equals sign here"})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
|
||||
async def test_blank_map_clears_the_entries(self, tmp_path):
|
||||
flow = self._options_flow(
|
||||
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
||||
)
|
||||
|
||||
result = await flow.async_step_init({"app_name_map": " "})
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert flow._options["app_name_map"] == {}
|
||||
|
||||
async def test_existing_map_prefills_the_text_field(self, tmp_path):
|
||||
flow = self._options_flow(
|
||||
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
||||
)
|
||||
|
||||
# first visit: the form carries the serialized current map as the
|
||||
# field default
|
||||
result = await flow.async_step_init(None)
|
||||
|
||||
assert result["type"] == "form"
|
||||
# the editor carried the existing map through the defaults path
|
||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||
|
||||
|
||||
class TestAppNameMapResolution:
|
||||
"""app_name resolves through the map after the entity/push tiers."""
|
||||
|
||||
async def test_mapped_package_resolves_the_friendly_name(self):
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = "" # standalone
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||
|
||||
assert player.app_name == "TiviMate"
|
||||
assert player.app_id == "ar.tvplayer.tv" # app_id stays raw
|
||||
|
||||
async def test_unmapped_package_stays_raw(self):
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = ""
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("com.google.android.youtube.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert player.app_name == "com.google.android.youtube.tv"
|
||||
|
||||
async def test_entity_tier_still_wins(self):
|
||||
from types import SimpleNamespace as SimpleNS
|
||||
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = "media_player.atv"
|
||||
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
||||
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||
|
||||
player._manager._on_current_app("ar.tvplayer.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert player.app_name == "ATV Tivi" # entity tier, map untouched
|
||||
Reference in new issue
Block a user