Files

32 KiB
Raw Permalink Blame History

ha-nvidia-shield MVP Plan (Shield-only fork)

Scope: Shield-only. Primary state = ADB media session tracking (dumpsys media_session), persistent event-driven connection, with a fallback path for apps that don't publish media sessions. ADB pairing flow stays intact. Companion doc: docs/RESEARCH_SHIELD.md (firmware research results, fixtures, and the user's current YAML glue).

Decisions locked:

  • Option 1 adopted: absorb the remote protocol (androidtvremote2) — all-in-one device.
  • TiviMate (ar.tvplayer.tv) publishes no media session → headline fallback app.
  • play_media(channel) replicates core androidtv_remote behavior exactly (digits, 0.1 s delay, no ENTER).

1. Current state assessment

Architecture as found

config_flow.py ── user step → adb_pair step (one-shot connect test)
__init__.py     ── no runtime object; hass.data[DOMAIN][entry_id] = entry (nothing!)
adb_session.py  ── AdbMediaSessionPoller = AdbClientBase + poll() (thin)
media_player.py ── the real "coordinator": entity owns ADB socket, polling loop,
                   intent tracking, state tiering, Jellyfin/media-info, power logic
sensor.py       ── 2 read-only mirror sensors
button.py       ── Pair ADB (throwaway poller, connect→disconnect→reload)
jellyfin.py     ── piggybacks on HA core Jellyfin coordinator cache (zero extra HTTP)
power.py        ── sibling-sensor discovery for the smart plug
ha_adb_common/  ── vendored: adb_client (connect/shell/keyevents/media_session poll),
                   media_session parser, running_apps, intent dataclass, keys manager

There is no DataUpdateCoordinator and no shared runtime object. The entity (NvidiaShieldMediaPlayer) is the coordinator: it creates the poller in async_added_to_hass, runs async_call_later polling, and writes state itself.

What already works (keep)

Area File Notes
ADB key lifecycle: generate, migrate from legacy paths, namespace per domain ha_adb_common/keys.py Solid; pairing-flow compatible. Keep untouched.
Pairing UX: config-flow step + Pair ADB button + "approve on Shield" guidance config_flow.py, button.py Works; keep intact per constraint.
ADB connect via adb_shell (AdbDeviceTcpAsync + RSA signer in executor) adb_client.py Correct executor usage; blocking I/O off the loop.
Media session parsing: package/active/state/metadata from dumpsys media_session; foreground-app preference; playing>paused ordering media_session.py Works on Shield Android 11; version-drift risk (see fragility).
Optimistic UI: PlaybackIntent + self-heal + one retry + external-override detection intent.py, media_player.py Good design; survives rewrite.
State tiering: power → intent → Jellyfin → ADB session → ON media_player.py state property Sound priority model.
Jellyfin metadata piggyback (resolution/HDR/audio labels from core coordinator cache) jellyfin.py Zero-cost; keep.
Power: ADB wake as authority, plug "off" as hard off, sibling sensor discovery power.py, media_player.py Keep.
Running apps from dumpsys activity recents adb_client.py, running_apps.py Useful for Media DAG switcher.
Media commands: ADB keyevent when connected & Jellyfin inactive, else androidtv_remote media_player.py _media_cmd Sensible dual path; superseded by protocol-first routing in Phase 2.

What is fragile

  1. Connection ownership is in the entity. The ADB socket, its poll timer and all state live on NvidiaShieldMediaPlayer. Reload (options change triggers full entry reload via _async_update_listener) tears down and re-handshakes the ADB connection. Sensors have no access to ADB data. A second entity is impossible.
  2. Untracked in-flight poll tasks. _adb_poll is dispatched via async_create_task and never cancelled on unload; an in-flight shell() after disconnect() hits shell()'s auto-reconnect → surprise reconnect after removal.
  3. Silent failure mode. shell() swallows errors at DEBUG, returns None, marks disconnected, and the next poll is 300 s later. Meanwhile self._adb_awake and last_info keep their last values — the entity can report playing for minutes while the device is asleep/dead. No staleness/expiry on media session info.
  4. Wake detection is a substring. "Awake" in dumpsys power result — works on this firmware (mWakefulness=Awake/Asleep, verified R5) but fragile against future values like Dozing; switch to exact value match.
  5. Parser depends on exact dumpsys text (package=, active=true, state=PlaybackState state=3/2, description= positional split) and head -200 truncates the dump — sessions below the cut are lost. No position parsed from PlaybackState despite it being present (verified R9: position/ms + uptime-ms updated).
  6. Intent retry sends the wrong path. _on_intent_timeout retries via _cmd() (androidtv_remote) even when the original command succeeded via ADB.
  7. Dead/duplicated logic. jf_active computed and unused in _adb_poll; _resolve_adb_host duplicated in 3 places; JELLYFIN_APP_IDS defined but never used (Wholphin routing); Current App sensor merely mirrors androidtv attributes.
  8. State writes gated on change. _adb_poll writes HA state only when wake state flipped or last_info exists — running_apps changes and info clears can be missed; stale attribute windows.
  9. power is False → IDLE not OFF. ADB-asleep maps to IDLE (standby) by design, but the plug-off check happens before ADB, so semantics are split across two places and easy to break.
  10. No tests, no CI lint gate (ruff/pytest configured in pyproject.toml but zero test files). A rewrite will be blind.
  11. iot_class: local_polling + per-entity 10 s polling of three dumpsys commands (power, media_session, activity recents) — wasted round trips when nothing changed.

What's missing for MVP

  • Persistent connection manager with watchdog + backoff (nothing tracks connection health; the only "watchdog" is the next poll).
  • Event-driven trigger source (foreground-app change events) — today everything is timer-driven.
  • Fallback foreground/state path for apps without media sessions (TiviMate is the headline case — verified no session, R2).
  • Playback position from media sessions (+ uptime-anchored math, R9).
  • Media session staleness handling (session gone → transition to idle).
  • Tests + CI (lint + unit) to make the rewrite safe.

2. Event-driven rewrite: keep or replace the polling layer?

Decision: replace the per-entity poll loop with an entry-scoped ShieldConnectionManager that runs a hybrid event/heartbeat loop. Do not try to make logcat a true push stream over adb_shell for MVP.

Why not pure logcat push (yet)

CORRECTION (C-STREAM, spike-verified on our pinned range): adb_shell 0.4.4 does ship streaming_shell — a generator yielding each line of output — on both the sync AdbDevice and async AdbDeviceAsync (async needs the tiny aiofiles + async_timeout extras, both already HA-ecosystem staples). So a true logcat push stream needs neither internals hacks nor a new major dependency. What keeps the hybrid design honest is device behavior, not the library: logcat does not reliably carry playback state for third-party apps — it reliably carries foreground/app lifecycle events (verified R8: ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>} on every app open, including home-button → launcher). Streaming stability over multi-hour connections still needs a device spike before we make it the sole trigger.

The hybrid design (target architecture)

entry.runtime_data = ShieldConnectionManager
  ├─ transport A: androidtvremote2 protocol client (Phase 2)
  │    push callbacks: is_on, is_available, current_app, volume_info
  │    commands: send_key_command, send_launch_app_command, IME
  ├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
  ├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
  ├─ trigger listener: foreground-change events
  ├─ trigger listener: foreground-change events
  │    Phase 1: logcat delta snapshots ~2–5 s cadence (safe default)
  │    Phase 2+: streaming_shell logcat stream on a dedicated connection
  │    (library support verified on 0.4.4; pending device stability spike)
  ├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
  │    → if changed → `dumpsys media_session` fetch
  ├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
  ├─ parsed StateStore: {is_on, foreground_pkg, sessions[], audio_playback[],
  │    last_updated ts per field}
  └─ event API: listener registration so entities are thin views

Net effect vs today: one persistent socket instead of reconnect-on-failure; wake-ups driven by device events within seconds; heartbeat as fallback; wake check folded into the foreground query instead of a separate grep; per-cycle cost drops from 3 dumpsys commands × every 10 s to ~1 command × on-event + 1 × per heartbeat.

Blocking issues to fix before/during the rewrite (flagged)

# Blocker Where Fix
B1 Connection owned by entity; no shared runtime object media_player.py _setup_adb, __init__.py Move connection+state into ShieldConnectionManager stored in entry.runtime_data; entities subscribe. Mirror HA core androidtv's entry.runtime_data dataclass pattern.
B2 Untracked async_create_task polls escape unload media_player.py _adb_poll_callback Manager owns its tasks; cancel on async_stop + EVENT_HOMEASSISTANT_STOP; guard shell() reconnect behind explicit lifecycle calls.
B3 shell() auto-reconnect hides failure from callers adb_client.py shell Explicit error propagation; reconnect handled only by watchdog. Adopt androidtv core's adb_decorator shape: catch known ADB exceptions → log once → close → mark unavailable.
B4 head -200 truncation + parser text-coupling adb_client.py, media_session.py Remove head; tolerant parser w/ real firmware fixtures (captured — docs/RESEARCH_SHIELD.md).
B5 Stale-state window (no TTL on last_info/_adb_awake) media_player.py StateStore carries timestamps; info older than TTL (e.g. 2×heartbeat) is expired.
B6 Options change reloads entry → full reconnect churn __init__.py update listener Adopt core androidtv's pattern: only reload for structural options (RELOAD_OPTIONS), otherwise re-read options via async_dispatcher_send.

3. Fallback mechanism for apps without media sessions

Options considered:

Option Reliability Cost Notes
A. Foreground app via dumpsys activity activities (mResumedActivity / topResumedActivity) High on Shield (Android 11/12 line) 1 cheap command, on-event only Deterministic current app; works for every app. Verified on this Shield (R4): mResumedActivity is the Android-11 pattern; topResumedActivity kept as newer-firmware fallback.
B. Intent tracking (Android usage/intent logs) Low–medium Higher parse cost Requires AccessibilityService/app instrumentation on modern Android — not viable via ADB shell. Reject.
C. Last-known-state cache (retain last session per package with timestamp) Medium (supplementary) Memory only No extra polling; answers "what was this app doing".
D. logcat foreground listener High for events Part of the listener work Covered by the hybrid loop above; R8 verified ActivityTaskManager: START lines.
E. "Audio playback" section of dumpsys media_session High as a hint Zero (same command) NEW from R2: lists recently-playing audio packages even with no session (ar.tvplayer.tv shows there). Confidence check that a foreground app is actually playing media.

Recommendation: build A + C — with the Option-1 decision (§9), the absorbed protocol client's current_app push is the primary foreground signal. A (mResumedActivity dumpsys) is the fallback when the protocol client is down or silent; C (last-known-state cache) turns "foreground but no session" from UNKNOWN into "in (idle / last known paused)"; E enriches the fallback tier with a "media recently active" hint at zero round-trip cost. D lands as the ADB-side trigger in Phase 1.

Resulting state ladder for MVP: protocol is_on → intent → Jellyfin → media session → foreground fallback (current_app push, then ADB mResumedActivity; state = "on, in app X", with last-known metadata if any) → ON. Audio-playback identity (E) enriches the fallback tier with a "media recently active" hint.


4. Phased MVP plan

Phase 0 — Stabilize & trim (foundation; ~1–2 days)

Goal: make the codebase safe to rewrite. No behavior change.

Files:

  • custom_components/nvidia_shield/__init__.py — introduce entry.runtime_data placeholder (shared per-entry context object, initially just config mirror).
  • custom_components/nvidia_shield/const.py — add TTL/staleness constants, backoff constants; wire the currently-unused JELLYFIN_APP_IDS and add com.github.damontecres.wholphin + ar.tvplayer.tv package constants.
  • custom_components/nvidia_shield/adb_session.py + ha_adb_common/adb_client.py — de-duplicate host resolution into one helper; make shell() propagate errors (B3) while keeping current callers working; stop swallowing poll errors silently.
  • custom_components/nvidia_shield/media_player.py — remove dead jf_active; fix intent retry to use the path that originally succeeded; track+cancel poll tasks on unload (B2); write state whenever any ADB-derived value changes.
  • tests/ (new) — parser fixtures (real dumpsys media_session, activity recents outputs), intent-logic unit tests, config-flow smoke tests. Real firmware fixtures are already captured — see the fixtures table in docs/RESEARCH_SHIELD.md.
  • pyproject.toml/CI — run ruff + pytest on PR.

Risks: parser fixture drift across Shield firmware (mitigate: fixtures from 2+ firmware versions, tolerant regexes). Fixing shell() error propagation can expose previously hidden reconnect bugs — expected, ship together with the task-tracking fix.

Done when: all tests green; no in-flight task survives entity unload (test proves it); intent retry honors command path; ruff clean.

Phase 1 — Core playback state + connection reliability (~3–5 days)

Goal: reliable, fast, persistent connection; media session is the source of truth.

Files:

  • custom_components/nvidia_shield/connection.py (new) — ShieldConnectionManager: persistent socket, watchdog + backoff reconnect, on-demand fetch API, StateStore with timestamps, listener registration. API designed to host a second transport (the remote protocol joins in Phase 2).
  • custom_components/nvidia_shield/adb_session.py — becomes thin adapter over the manager (keep ensure_adb_key re-export for config flow/button).
  • ha_adb_common/adb_client.py — extract protocol-level commands (no loop scheduling); add explicit health_check().
  • ha_adb_common/media_session.py — parse PlaybackState position (ms, with uptime-anchored math — updated is uptime-ms per R9); add session-expiry at store level.
  • media_player.py, sensor.py — entities become views over StateStore; state tiering unchanged; availability reflects manager connection health (_attr_available).
  • manifest.json — iot_class: local_polling stays until the protocol client (push) lands in Phase 2, then becomes local_push.
  • config_flow.py — pairing step reuses manager's test-connect (no behavior change to flow).

Risks: adb_shell async stability over long-lived connections (mitigate: watchdog pings + teardown/rebuild on protocol errors); reconnect storms during device reboots (backoff + jitter); intent logic regression (covered by Phase-0 tests).

Done when: device reboot recovers automatically within backoff window; play/pause reflected in ≤1 heartbeat cycle (≤30 s) worst case, foreground-triggered changes faster; unavailable/available transitions correct; no state staleness beyond TTL; tests for manager (connect/reconnect/expiry) pass.

Phase 2 — All-in-one: absorb the remote protocol (androidtvremote2) (~4–6 days)

Goal: one integration, one device. We own the Google remote protocol for commands, power and availability; ADB remains the playback-state source. The external androidtv / androidtv_remote integrations are no longer required.

Files:

  • manifest.json — add androidtvremote2 requirement (adb-shell stays).
  • config_flow.py — protocol pairing ported from core androidtv_remote: generate cert if missing, fetch name + MAC (unique_id), PIN pair step (code shown on the Shield), reauth flow on InvalidAuth (device hard reset). The existing ADB pairing step stays intact — both pairings coexist in one flow.
  • connection.py — manager now hosts both transports: protocol client (keep_reconnecting, callbacks: is_on, is_available, current_app, volume_info)
    • ADB client; one StateStore fed by both.
  • remote.py (new platform) — protocol-backed remote entity (port of core: SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity = current app).
  • media_player.py — command routing switches to protocol keys; volume gains a push-based level readout + real mute state (NOTE: androidtvremote2 has NO absolute volume set — HA core closed volume_set for androidtv_remote as not_planned; only VOLUME_STEP | VOLUME_MUTE are claimed, level comes from volume_info push); availability and is_on come from protocol callbacks; _is_power_on ladder: protocol is_on → ADB wake → plug off. plug off.
  • media_player.py async_play_media — §7 channel port via protocol send_key_command digits (0.1 s delay) + task-cancel guard; ADB keyevent fallback when the protocol is down.
  • __init__.py — PLATFORMS gains REMOTE; entry migration (VERSION 2); close both clients on HA stop / unload.

Risks: two pairing experiences (ADB + PIN) need clear flow copy; androidtvremote2's own reconnect task and our ADB watchdog must not fight each other — one owner per transport; existing installs that wrap the external androidtv_remote entity need a migration path (transitional fallback while it's still configured); IME option defaults off.

Done when: entry sets up standalone (no androidtv/androidtv_remote entries needed); remote entity sends keys and launches apps; real volume level/mute shown; channel selection works via protocol with ADB fallback; device hard reset triggers reauth; ADB pairing unchanged; migration covered by tests.

Phase 3 — Fallback app state (~2–3 days, smaller now)

Goal: every app gets a sensible state even without a media session. The protocol client's current_app push is the primary foreground signal; ADB fills gaps.

Files:

  • ha_adb_common/adb_client.py (or new foreground.py) — mResumedActivity parser (Android 11 primary pattern; topResumedActivity kept as a newer-firmware fallback) — used only when protocol current_app is None/unknown/stale. Do not parse dumpsys window focus first (multiple mCurrentFocus lines incl. literal null — R4).
  • connection.py — foreground priority in StateStore: protocol current_app push

    ADB mResumedActivity; last-known-state cache per package (option C); audio-playback identity parsed from the same media_session dump (E).

  • ha_adb_common/media_session.py — parse the "Audio playback (lastly played comes first)" section into {package: last_played_hint}; ignore-list com.google.android.apps.mediashell (Chromecast receiver process).
  • media_player.py — state ladder gains the foreground tier; running_apps enrichment with foreground/last-known info.
  • sensor.py — Current App sensor reads StateStore (protocol push makes it accurate without androidtv attribute reads).
  • README — document the state ladder and fallback semantics; document the migration away from mediasession2mqtt sensors + the universal-player YAML glue (appendix in docs/RESEARCH_SHIELD.md).

Risks: dumpsys format differences across Shield Experience releases (fixtures from real devices; R4); apps that keep background sessions while another is in the foreground (session-vs-foreground preference already handles); last-known cache must never resurrect stale "playing".

Done when: launching an app with no media session shows correct app + "on/idle" within one update cycle; returning to an app with a session shows its state; state tracks current_app push with no attribute polling; stale cache never reports playing after >TTL; play_media (media_content_type: "channel") selects TiviMate channels by number (protocol path, ADB fallback) per §7; tests cover all ladder branches.

Phase 4 — QoL (~ongoing)

Files:

  • Deep links — now cheap: media_type: app/url → protocol send_launch_app_command (port of core pattern; the client auto-prefixes market://launch?id= for bare package ids — C-LAUNCH, verified in the client source, so no extra branch for app-id vs URI) + BrowseMedia app directory; per-app am start extras via ADB only for apps the protocol can't launch (data-driven map in const; discovery recipe via adb shell cmd package dump <pkg> in RESEARCH_IDEAS.md).
  • Channel keys: add channel_up/channel_down (proto keycodes 166/167 — trivial, no UI needed) alongside the §7 digit path.
  • Options: apps name/icon map editor (core androidtv_remote pattern), digit delay, IME toggle, privacy scope; finish config entry migration if needed.
  • Zeroconf discovery (_androidtvremote2._tcp) — auto-fill host, update entry on IP change; manifest.json gains the zeroconf integration dependency.
  • adb_command service + diagnostics (port androidtv patterns).
  • Optional: streaming_shell logcat stream as the trigger source (library support verified on adb-shell 0.4.4, spike at goal round 9; pending a multi-hour device stability spike) behind an option flag; default stays delta snapshots.
  • Trim: fold Current App sensor if StateStore covers it; drop running_apps_from_sessions legacy; remove transitional external-entity code.

Risks: deep-link semantics differ per app (intent extras) — keep the app map data-driven; options migration bugs (test entry migration).

Done when: deep links work for 2–3 reference apps; apps editor works; discovery updates host on IP change; docs updated.


5. Dependency & compatibility notes

  • Runtime deps: adb-shell>=0.4.0 + androidtvremote2 (adopted in Phase 2 — small, HA-core-blessed); zeroconf integration dependency joins in Phase 4 for discovery. Logcat streaming is the only thing that might eventually need internals or a new dep — explicitly deferred and flagged.
  • Vendored ha_adb_common stays for HACS; keep the hisense_tv consumer in mind when changing its API (add, don't break; the fork can diverge later but Phase 0–2 changes should remain additive where possible).
  • Pairing flow: ADB pairing (async_step_adb_pair, Pair ADB button, ensure_adb_key, .storage/nvidia_shield/adbkey layout) is untouched in all phases. The remote-protocol cert/PIN pairing (.storage certs) is added alongside it in Phase 2.

6. Sequencing summary

Phase Theme Primary outcome
0 Stabilize/trim, tests Safe rewrite base; no behavior change
1 Persistent connection + session truth Reliable fast playback state, auto-recovery
2 Absorb remote protocol (androidtvremote2) All-in-one device; channel select; focused-app push
3 Foreground fallback + last-known state Full-app coverage without sessions
4 Deep links, apps editor, discovery, diagnostics Polish

7. TiviMate channel selection (play_media port)

Requirement: retain media_player.play_media with media_content_type: "channel" so TiviMate channels can be selected by number (e.g. media_id: "1"). This logic lives today in the Android TV Remote integration; with Option 1 adopted we port it onto our own protocol client (primary path) with ADB keyevents as fallback.

Port design

  • Add MediaPlayerEntityFeature.PLAY_MEDIA and implement async_play_media(media_type, media_id).
  • When media_type == "channel": extract digits from media_id, then send each digit as an ADB keyevent — KEYCODE_0 = 7 … KEYCODE_9 = 16 (digit n → keycode 7+n) — with a configurable delay between digits (default ~0.5 s), then a confirm keycode. Reference (verified from HA core androidtv_remote/media_player.py): core sends digits ONLY — media_id.isnumeric(), cancels any prior in-flight channel task (concurrency guard), delay_secs=0.1, and never sends a confirm key: the device's channel overlay auto-selects. Port: digit n → keycode 7+n; make ENTER an optional boolean (default off — replicates the androidtv_remote behavior the user confirms works today).
  • Other media_type values (app, url): launch via the protocol client's send_launch_app_command once Phase 2 lands; until then reject unknown types with a log warning.
  • Fallback chain: protocol client down → ADB input keyevent digits (keycode 7+n, slower but socket-independent) → transitional only: delegate to the still-configured external androidtv_remote entity (migration path, removed in Phase 4). Order: protocol first, ADB second, legacy third.
  • Command path: protocol send_key_command (push-based, fast, no extra shell round trip); ADB keyevents only as the fallback (works whenever the socket is up, regardless of the remote protocol's state).
  • Timing risk: too-fast digits can be dropped or the overlay can close early — make the inter-digit delay a keyvalue option with a safe default (0.1 s, core-verified); the live check (R7) remains optional.
  • Service-call note: current HA syntax passes data.media.media_content_id / media_content_type; async_play_media receives "channel" / "<n>" the same as before.

Metadata implication

Channel metadata (EPG name, title) for TiviMate is a separate question from channel selection:

  • TiviMate: no media session (R2 verdict) → now-playing = Phase-3 foreground fallback: state = "on, in TiviMate", title = last_channel cache when play_media fires (cheap, honest, no EPG claim). Full EPG/EPG-logo data is out of scope for the ADB-only MVP.
  • Jellyfin-family clients: Wholphin (com.github.damontecres.wholphin) publishes a proper session with usable metadata (R9) — routes through JELLYFIN_APP_IDS to the rich-metadata path.
  • Netflix publishes a stale inactive session (active=false, state=null) — the parser must keep ignoring it; a fixture pins this behavior.

Where it lands

  • Phase 2 (the port target is the protocol client absorbed in Phase 2; TiviMate state display comes from Phase 3's fallback).

8. Local research on the Shield — STATUS

STATUS: DONE. R1–R5, R8, R9 executed; R6 optional-not-run; R7 skipped by decision. Full results, raw captures, and the user's current YAML glue are in docs/RESEARCH_SHIELD.md.

Step Status Key result
Setup ✅ Shield mdarcy, Android 11, RQ1A.210105.003; custom launcher com.spocky.projengmenu
R1 ✅ TiviMate package = ar.tvplayer.tv (plan's iv.android.tv guess was wrong)
R2 ✅ No media session for TiviMate (branch 3). Netflix stale inactive session only. NEW: "Audio playback" section lists ar.tvplayer.tv
R3 ⏳ partial Wholphin + Netflix captured; YouTube/Spotify/Plex optional
R4 ✅ mResumedActivity is the Android-11 pattern; no topResumedActivity; mCurrentFocus=null trap
R5 ✅ mWakefulness=Awake/Asleep — switch to exact value match
R6 ⏳ Not run (optional; low risk)
R7 ⏭ skipped Replicate core behavior (digits, 0.1 s, no ENTER) — user's existing flow works
R8 ✅ ActivityTaskManager: START lines reliable incl. home button; user has be.digitalia.mediasession2mqtt installed
R9 ✅ PlaybackState{state=3, position (ms), speed, updated (uptime ms)} parseable; Wholphin session good (description=Twirlywoos)

9. HA core research: androidtv + androidtv_remote (what's worth taking)

Sources reviewed on dev: androidtv · androidtv_remote.

Key findings first:

  1. androidtv (ADB component) has NO async_play_media — channel-by-number logic is not there. It lives in androidtv_remote/media_player.py, so §7's port source is confirmed to be androidtv_remote (not androidtv).
  2. Both components ship PLATFORMS = [MEDIA_PLAYER, REMOTE] — HA core's own precedent for one device exposing a media_player + remote entity pair. That is exactly the all-in-one shape requested.
  3. androidtv's remote entity is pure ADB (input keyevent) — a basic remote entity needs no remote-protocol dependency (superseded by the Option-1 decision; its logic survives as our command fallback path).

Worth taking from androidtv (ADB component)

Pattern File Fold into
entry.runtime_data dataclass: client + options snapshot __init__.py Phase 1 manager (B1) — canonical HA pattern to mirror
ConfigEntryNotReady on first connect failure → HA itself retries setup __init__.py Phase 1 — better than our in-entity 300 s retry
Close connection on EVENT_HOMEASSISTANT_STOP __init__.py Phase 1 manager lifecycle
Options change without full reload: only reload for RELOAD_OPTIONS; otherwise re-read options via async_dispatcher_send __init__.py update_listener Proper fix for B6 (reload churn)
adb_decorator: uniform exception handling → log once, adb_close(), mark entity unavailable entity.py Phase 1 — replaces swallow-at-DEBUG shell() (B3)
Reconnect-on-update + _failed_connect_count (log first failure only) media_player.py async_update Phase 1 watchdog detail
select_source = launch app; !name prefix = close app; source_list built from running apps media_player.py Phase 3/4 QoL — natural fit with our existing running_apps
adb_command service, diagnostics services.py, diagnostics.py Phase 4 QoL

Worth taking from androidtv_remote (remote protocol)

Pattern File Fold into
Channel logic (the §7 port source): media_type == CHANNEL, media_id.isnumeric(), cancel previous in-flight channel asyncio.Task, _send_key_commands(list(media_id), delay_secs=0.1) — digits only, no ENTER media_player.py §7
app/url media types → launch app; BrowseMedia app directory media_player.py Phase 4 deep links + browse
Entities as thin callback-views over one runtime API object (available / is_on / current_app / volume callbacks) entity.py Phase 1 manager → entity design
keep_reconnecting() + trigger reauth when the device hard-resets (cert invalid) __init__.py Phase 1/2 watchdog: distinguish "unreachable" (backoff) from "unpaired" (reauth)
Zeroconf discovery (_androidtvremote2._tcp, MAC as unique_id, host auto-update on IP change) config_flow.py Phase 4 QoL
Cert+PIN pairing, certs stored in .storage config_flow.py, helpers.py Phase 2

All-in-one: the remote as an entity alongside the media_player — DECIDED

Today we wrap an external androidtv_remote entity (resolving its host from its config entry). Decision: Option 1 adopted — androidtvremote2 is excellent for commands but weak on receiving playback state; our ADB session tracking fills exactly that gap, making the two transports complementary. Phase 2 absorbs the protocol: we own the connection, one device provides media_player + remote, and the current_app push becomes the primary foreground signal — fewer dumpsys round trips and a more reliable Phase-3 fallback. ADB remains the state source; the ADB pairing flow stays intact. The ADB-only remote entity (Option 2) is not built as a separate platform; its input keyevent logic survives as the command fallback path inside async_play_media/command routing.