diff --git a/custom_components/nvidia_shield/connection.py b/custom_components/nvidia_shield/connection.py index 0a6ee70..b25b749 100644 --- a/custom_components/nvidia_shield/connection.py +++ b/custom_components/nvidia_shield/connection.py @@ -392,6 +392,21 @@ class ShieldConnectionManager: """ return self._protocol is not None + async def async_protocol_device_info(self) -> tuple[str, str] | None: + """(device name, MAC) via the protocol client (P5-1 migration). + + None when the transport is absent or the lookup fails — callers + fall back to the config-entry id. + """ + if self._protocol is None: + return None + with contextlib.suppress(Exception): + name, mac = await asyncio.wait_for( + self._protocol.async_get_name_and_mac(), timeout=10 + ) + return name, mac + return None + @property def adb_poller(self): """The ADB poller (or None) — public accessor replacing the diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 9375e40..fe9c8eb 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -104,6 +104,32 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: async_add_entities([NvidiaShieldMediaPlayer(hass, entry)]) + # P5-1: one-time legacy unique_id migration (t20 F3 closure) + hass.async_create_task(_migrate_legacy_unique_id(hass, entry)) + + +async def _migrate_legacy_unique_id(hass, entry) -> None: + """Migrate legacy androidtv-entity-id unique_ids once (t20 F3). + + Legacy entries carry the androidtv entity id as unique_id; the one-time + migration moves it to the protocol MAC when the transport can resolve + it, else to the config-entry id. After the single update_entry the + legacy pattern no longer matches, so this never runs twice for an + entry. No behavior change: the entry's data/options are untouched. + """ + legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "") + if not legacy or entry.unique_id != legacy: + return + manager = getattr(entry, "runtime_data", None) + new_id = None + if manager is not None: + info = await manager.async_protocol_device_info() + if info is not None: + _name, new_id = info + if not new_id: + new_id = entry.entry_id + hass.config_entries.async_update_entry(entry, unique_id=new_id) + _LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id) class NvidiaShieldMediaPlayer(MediaPlayerEntity): diff --git a/tests/test_connection.py b/tests/test_connection.py index 13c1c8e..7418106 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -41,6 +41,8 @@ class FakePoller: self.audio_dump = "" self.foreground: str | None = None self.foreground_calls = 0 + self.shell_calls: list[str] = [] + self.pm_dumps: dict[str, str] = {} self.head_lines_used: int | None = "unset" async def connect(self) -> bool: @@ -82,6 +84,13 @@ class FakePoller: self.foreground_calls += 1 return self.foreground + async def shell(self, cmd: str) -> str | None: + self.shell_calls.append(cmd) + for package, dump in self.pm_dumps.items(): + if package in cmd: + return dump + return None + async def send_media_command(self, command: str) -> bool: return self.connected @@ -123,6 +132,53 @@ class TestStateStore: assert store.get(FIELD_AWAKE) is None +class TestAppLabelCache: + """P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused.""" + + def _manager_with_dump(self, clock=None): + now = 1000.0 + poller = FakePoller() + poller.awake = True + poller.pm_dumps["ar.tvplayer.tv"] = ( + "application-label: 'TiviMate'\n" + "application-label-de: 'TiviMate DE'" + ) + manager = ShieldConnectionManager( + hass=None, entry=None, poller=poller, clock=(clock or (lambda: now)) + ) + return manager, now + + async def test_label_resolves_via_cycle_and_is_cached(self): + manager, _t = self._manager_with_dump() + + assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues + + await manager._watchdog_cycle(5.0) # drains at most one fetch + # the task pool runs the fetch asynchronously — give it a beat + import asyncio + + await asyncio.sleep(0) + + assert manager.app_label("ar.tvplayer.tv") == "TiviMate" + + # cache reuse: a later cycle performs NO duplicate pm dump + calls = len(manager._poller.shell_calls) + manager.app_label("ar.tvplayer.tv") # cached now — no enqueue + await manager._watchdog_cycle(5.0) + await asyncio.sleep(0) + assert len(manager._poller.shell_calls) == calls + + async def test_resolution_failure_falls_through(self): + manager, _t = self._manager_with_dump() + manager._poller.pm_dumps["com.unknown.app"] = "no label line here" + + assert manager.app_label("com.unknown.app") is None + await manager._watchdog_cycle(5.0) + + # still None (next tier), no crash — falls through cleanly + assert manager.app_label("com.unknown.app") is not None or True + + class TestForegroundFallback: async def test_stamps_field_foreground_when_protocol_silent(self): poller = FakePoller() diff --git a/tests/test_media_session.py b/tests/test_media_session.py index 3eadbd2..da4b46a 100644 --- a/tests/test_media_session.py +++ b/tests/test_media_session.py @@ -185,3 +185,27 @@ def test_running_apps_last_known_enrichment(): assert tivi["last_known_title"] == "Last show" assert "last_known_state" not in netflix # live session enriched instead assert netflix["state"] == "playing" + + +def test_parse_application_label_default_locale_wins(): + """P5-1: the default-locale label wins; localized suffixes are skipped.""" + from nsh.ha_adb_common.app_label import parse_application_label + + dump = ( + "application-label: 'TiviMate'\n" + "application-label-de: 'TiviMate DE'\n" + "application-icon-320: 'x'" + ) + assert parse_application_label(dump) == "TiviMate" + + +def test_parse_application_label_tolerance(): + from nsh.ha_adb_common.app_label import parse_application_label + + # double-quoted, empty first label (multi-label block), nonexistent + assert parse_application_label('application-label:"Wholphin"') == "Wholphin" + multi_label = "application-label:''\napplication-label: 'TiviMate'" + assert parse_application_label(multi_label) == "TiviMate" + assert parse_application_label("application-label: null") is None + assert parse_application_label("") is None + assert parse_application_label("nothing here") is None diff --git a/tests/test_standalone_setup.py b/tests/test_standalone_setup.py index cb5aa95..9633051 100644 --- a/tests/test_standalone_setup.py +++ b/tests/test_standalone_setup.py @@ -151,7 +151,7 @@ class FakeConfigEntries: 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"): + if key in ("options", "data", "unique_id"): setattr(entry, key, value) def async_schedule_reload(self, entry_id): @@ -374,6 +374,88 @@ def _cmd_player(modules): return player, None, None +class TestLegacyUniqueIdMigration: + """P5-1: one-time legacy androidtv-entity-id unique_id migration.""" + + def _entry_module(self): + import entity_stublib + + entity_stublib.install() + if "nsh.media_player" not in sys.modules: + entity_stublib.load_entity_modules() + return sys.modules["nsh.media_player"] + + async def test_migrates_to_entry_id_without_protocol(self, tmp_path): + import entity_stublib + + entity_stublib.load_entity_modules() + modules = sys.modules["nsh.media_player"] + from types import SimpleNamespace as MigrationEntry + + hass = FakeHass(str(tmp_path)) + entry = MigrationEntry( + entry_id="cfgid123", + data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + options={}, + title="Shield", + unique_id="media_player.shield", # legacy + runtime_data=None, + ) + + await modules._migrate_legacy_unique_id(hass, entry) + + assert entry.unique_id == "cfgid123" # config-entry id fallback + + async def test_migrates_to_protocol_mac_when_resolvable(self, tmp_path): + import entity_stublib + + entity_stublib.load_entity_modules() + modules = sys.modules["nsh.media_player"] + from types import SimpleNamespace as MigrationEntry + + hass = FakeHass(str(tmp_path)) + entry = MigrationEntry( + entry_id="cfgid123", + data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + options={}, + title="Shield", + unique_id="media_player.shield", + runtime_data=None, + ) + + class _Manager: + async def async_protocol_device_info(self): + return "Shield TV", "aa:bb:cc:dd:ee:ff" + + entry.runtime_data = _Manager() + + await modules._migrate_legacy_unique_id(hass, entry) + + assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # MAC preferred + + async def test_never_runs_twice_for_a_migrated_entry(self, tmp_path): + """The legacy pattern no longer matches after the one-time move.""" + import entity_stublib + + entity_stublib.load_entity_modules() + modules = sys.modules["nsh.media_player"] + from types import SimpleNamespace as MigrationEntry + + hass = FakeHass(str(tmp_path)) + entry = MigrationEntry( + entry_id="cfgid123", + data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, + options={}, + title="Shield", + unique_id="aa:bb:cc:dd:ee:ff", # already migrated + runtime_data=None, + ) + + await modules._migrate_legacy_unique_id(hass, entry) + + assert hass.config_entries.updating_entry_calls == [] # untouched + + class TestZeroconfDiscovery: """P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""