refactor(config): drop the androidtv media_player entity path end-to-end (t41)

The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.

Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
  offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
  and, with it, every registry lookup for another integration's devices — the
  user step now sets NO unique_id at all; the protocol MAC learned during
  pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
  now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
  resolve_app_name chain, the Current App sensor, the sensor's state-change
  listener and its `_androidtv_state` helper) — the manager's store is the only
  source;
* the "transitional external entity" command tier in every path: `_cmd` and the
  pan-media_player service call are gone, and so is the fallback in _media_cmd,
  _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
  (an intent no transport served now surrenders to the truth instead of
  delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
  intent" check no longer watches another integration's entity; the power
  LADDER — protocol is_on → ADB wake → power switch — is unchanged).

turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.

helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.

Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.

Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
This commit is contained in:
James Mackay committed 2026-10-11 06:09:43 +01:00
1 parent 60d884b76a
commit 86aa2827a2
9 files changed
+247 -351

No files matched your search

+73 -52
View File
@@ -116,6 +116,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player._entry = entry
player._manager = manager
player._intent = None
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
player._last_channel = None
player.hass = entity_stublib.FakeHass()
player._entity_tasks = set()
@@ -123,13 +124,16 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player.async_write_ha_state = lambda: None
player._read = lambda eid: None
# t41: nothing may leave this integration, so an HA service call is now the
# failure signal rather than a supported tier. `served` stays empty unless a
# regression reintroduces the external-entity delegation.
served = []
async def _cmd(service, **kwargs):
served.append(("legacy", service))
return None
class _RecordingServices:
async def async_call(self, domain, service, data=None, **kwargs):
served.append((domain, service))
player._cmd = _cmd
player.hass.services = _RecordingServices()
captured_intents = []
@@ -248,7 +252,8 @@ class TestChannelDigits:
await player._channel_entry_sequence(["7"])
assert served == [("legacy", "play_media")] # transitional path used
# t41: both transports down is a no-op — no other integration is called
assert served == []
class TestAppAndUrls:
@@ -346,38 +351,67 @@ class TestRemoteEntity:
assert protocol.key_calls == []
class TestIntentRetryWithoutATransport:
"""t41: an intent no transport served surrenders instead of delegating."""
def _intent(self, **overrides):
from types import SimpleNamespace
intent = SimpleNamespace(
target_state="playing",
command="media_play",
created_at=0.0,
prior_state=None,
retry_count=0,
via_adb=False,
via_protocol=False,
adb_command=None,
protocol_key=None,
)
for key, value in overrides.items():
setattr(intent, key, value)
return intent
async def test_unserved_intent_surrenders_without_a_service_call(self, modules):
player, served, _intents = make_player(modules)
player.async_write_ha_state = lambda: None
player._intent = self._intent()
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
assert player._intent is None # surrendered to the truth
assert served == [] # and nothing left this integration
async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules):
player, served, _intents = make_player(modules)
player.async_write_ha_state = lambda: None
protocol = player._manager._protocol
player._intent = self._intent(
via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY"
)
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
await asyncio.sleep(0.05)
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
assert served == []
class TestAppFallback:
async def test_app_id_prefers_the_androidtv_entity(self, modules):
"""t41: the app identity comes from OUR transports' store only."""
async def test_app_id_comes_from_the_manager_store(self, modules):
player, _s, _i = make_player(modules)
player._androidtv_entity = "media_player.atv"
from types import SimpleNamespace as FakeState
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
states = FakeState(states=FakeState(
get=lambda eid: state if eid == "media_player.atv" else None
))
player.hass = states
player._read = lambda eid: state if eid == "media_player.atv" else None
assert player.app_id == "com.plex.hdx"
assert player.app_name == "Plex"
async def test_app_falls_back_to_protocol_current_app(self, modules):
player, _s, _i = make_player(modules)
player._androidtv_entity = "" # standalone setup (P3-1)
player._manager._on_current_app("com.netflix.ninja")
player._manager._on_current_app("com.plex.hdx")
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
assert player.app_id == "com.netflix.ninja"
assert player.app_name == "com.netflix.ninja" # package is truth; map later
assert player.app_id == "com.plex.hdx"
assert player.app_name == "com.plex.hdx" # package is truth; map later
async def test_app_unavailable_entity_falls_back(self, modules):
async def test_app_follows_the_latest_protocol_push(self, modules):
player, _s, _i = make_player(modules)
player._androidtv_entity = "media_player.atv"
from types import SimpleNamespace as FakeState
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
player._read = lambda eid: None
player._manager._on_current_app("com.netflix.ninja")
await asyncio.sleep(0.05)
player._manager._on_current_app("com.google.android.youtube.tv")
await asyncio.sleep(0.05)
@@ -393,7 +427,6 @@ def _ladder_player(modules, foreground=None, audio=None):
manager.store.set("foreground", foreground)
if audio:
manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None
return player, manager
@@ -411,7 +444,6 @@ class TestForegroundLadderTier:
manager.store.set("foreground", foreground)
if audio:
manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None
return player, manager
@@ -1187,12 +1219,16 @@ def make_manager_with(*, poller=None, protocol=None):
)
def make_app_sensor(modules, manager, entry, *, atv_state=None):
"""A Current App sensor over the given manager/entry (t40)."""
def make_app_sensor(modules, manager, entry):
"""A Current App sensor over the given manager/entry (t40).
t41: the sensor reads the manager's store and nothing else, so the harness
hands it a hass whose state machine is empty — a value here can only come
from our own transports.
"""
from types import SimpleNamespace
states = {"media_player.atv": atv_state} if atv_state is not None else {}
hass = SimpleNamespace(states=SimpleNamespace(get=states.get))
hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None))
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
@@ -1232,20 +1268,6 @@ class TestCurrentAppSensorFriendlyName:
assert sensor.native_value == "Netflix"
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
def test_androidtv_entity_tier_serves_when_no_map_or_label(self, modules):
from types import SimpleNamespace
player, _served, _intents = make_player(modules)
player._entry.data["androidtv_entity"] = "media_player.atv"
atv_state = SimpleNamespace(
attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"}
)
sensor = make_app_sensor(modules, player._manager, player._entry, atv_state=atv_state)
player._manager._on_current_app("ar.tvplayer.tv")
assert sensor.native_value == "ATV Tivi"
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
@@ -1271,7 +1293,6 @@ class TestCurrentAppSensorFriendlyName:
"""One chain, two entities — they cannot disagree about the app name."""
player, _served, _intents = make_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
player._androidtv_entity = "" # standalone, like the unit harness
sensor = self._sensor(modules, player)
player._manager._on_current_app("ar.tvplayer.tv")