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:
James Mackay committed 2026-10-08 11:03:28 +01:00
1 parent 61c7ac99a3
commit f4d750ff92
3 files changed
+246 -13

No files matched your search

+10 -6
View File
@@ -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."
}
}
}
+226 -1
View File
@@ -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