Author SHA1 Message Date
James Mackay 79faa32b68 docs: HA standards audit against current developer docs — deprecation gaps G3a/G3b/G8, YAML-import verdict, quality-scale notes (research)
Release / release (push) Successful in 6s
Test / test (push) Failing after 5s
2026-10-09 06:51:03 +01:00
James Mackay e51545262e fix(connection,adb_client): commit the F-NEW1..3 wiring lost in the amend cycle — no-idle-timeout transport budget with in-code rationale, config accessor consumption, wrapped-line buffer 2026-10-09 06:20:46 +01:00
James Mackay 9b3142874f fix(connection,adb_client): the logcat stream survives quiet windows — transport_timeout_s=None (bulk_read blocks quiet gaps; the ladder + async_stop remain recovery/cleanup), 3-line parse buffer for wrapped cmp records, public poller config accessor replaces the private reach-ins (t27 F-NEW1..3) 2026-10-09 06:20:38 +01:00
James Mackay 27715f6932 test(media_player): MAC-collision pre-check migration branches — occupied MAC falls back to the entry id, free MAC taken (t26 F4) 2026-10-09 06:13:06 +01:00
James Mackay 6c925c5241 chore(config_flow): replace the stranded self-answering comment in the options editor with a plain statement (t26 F5) 2026-10-09 06:11:58 +01:00
James Mackay 3587747c72 fix(media_player): unique_id migration pre-checks for an existing entry holding the target MAC — falls back to the config-entry id on collision (t26 F4) 2026-10-09 06:11:36 +01:00
James Mackay 2e63135745 test(media_player): spied sleep proves a non-default digit delay (0.3 s) reaches the channel sequence — option consumed per digit (t26 F3) 2026-10-09 06:11:28 +01:00
James Mackay 67174b8536 test(connection): drain beats before label-fetch asserts; label-less negative cache costs one pm dump ever + shlex-quote shape pinned (t26 F1,F2) 2026-10-09 06:10:27 +01:00
James Mackay b442440ccf fix(connection,app_labels): negative-cache label-less packages (same 24h TTL, no re-enqueue — kills the per-heartbeat pm dump tax) + shlex.quote the pm dump package argument (t26 F1,F2) 2026-10-09 06:08:33 +01:00
James Mackay 697e3ba307 feat(connection,const,strings): complete the P5-3 logcat consumer wiring lost in an earlier amend — dedicated stream, instant stamp, ladder + auto-disable, default-off toggle strings 2026-10-09 06:06:34 +01:00
James Mackay f863496ce5 test(media_session,connection): logcat START parsing (cmp=, wrapped records, null/non-START ignored, u-prefixes), instant stamp + notify, duplicate START suppression, 5-in-10-min auto-disable, OFF = no stream task, stream never touches the poller socket (P5-3) 2026-10-09 06:06:10 +01:00
James Mackay 07cb73fbb7 feat(config_flow,strings): CONF_LOGCAT_EVENTS options toggle (default off, stability-report wording) (P5-3) 2026-10-09 06:03:28 +01:00
James Mackay 0ecee66953 test(entity_stublib): ConfigFlow stub accepts the domain= __init_subclass__ kwarg and load_entity_modules drops config_flow (per-file loader ownership preserved) (completes P5-2 test scaffolding) 2026-10-09 06:00:20 +01:00
James Mackay a3b397920e test(config_flow,media_player,remote): digit-delay/send-enter options (store + window/error validation), IME-first text entry with ADB fallback tier, icon column parse/prefill/store (P5-2) 2026-10-09 06:00:17 +01:00
James Mackay 3d026ae60e feat(config_flow): digit-delay (0.05–1.0 s, invalid input error) + optional send-enter options UI consumed by the §7 channel path; strings/README updated (P5-2.1) 2026-10-09 05:55:07 +01:00
James Mackay 4661e52041 fix(ha_adb_common): application-label regex accepts double-quoted labels too (completes the P5-1 parser tolerance) 2026-10-09 05:52:21 +01:00
James Mackay c57a2e2ff2 test(ha_adb_common,connection): application-label tolerance (dual quotes, empty multi-label blocks, null, absent) + label cache reuse across cycles — no duplicate pm dump (P5-1) 2026-10-09 05:52:02 +01:00
James Mackay 2e6dfd8981 test(media_player): entity tier serves below map/manifest per the P5-1 resolution ruling (was map-beats-entity contradiction) 2026-10-09 05:49:18 +01:00
James Mackay cd0e634a76 feat(connection,media_player): on-demand manifest-label cache (≥24h TTL, one pm dump per watchdog cycle via the task pool) + app_name order map > label > entity > raw package (P5-1) 2026-10-09 05:48:39 +01:00
James Mackay 80e9a691bc feat(ha_adb_common): application-label parser — default-locale from pm dump, localized variants skipped, tolerant of empty/multi-label blocks (P5-1) 2026-10-09 05:47:30 +01:00
James Mackay 17648f43c9 fix(media_player): store the computed adb_command participant key on the entity — completes the F4 add/remove key symmetry in the lifecycle hooks 2026-10-08 11:22:23 +01:00
James Mackay f03ef1f531 fix(config_flow): per-host zeroconf updates — blank auto-detect hosts self-heal from the discovery address, a deliberately-different ADB host is never clobbered, same-IP with both hosts current is a no-op (F2) 2026-10-08 11:22:04 +01:00
James Mackay b37a14b920 fix(media_player): the adb_command participant registry key is computed once at registration and stored on the entity — add/remove always match (F4) 2026-10-08 11:21:09 +01:00
James Mackay c44c2c2170 feat(const,strings,readme): adb_command consts/service name, options toggle strings, curated DEFAULT_APP_DIRECTORY and README service notes (completes P4-2 unit 1) 2026-10-08 11:13:09 +01:00
James Mackay 326f02cb59 feat(media_player): BrowseMedia app directory — root lists the options apps map (curated TiviMate/Wholphin/Netflix/YouTube fallback), selection launches via play_media(app) (P4-2c) 2026-10-08 11:12:37 +01:00
James Mackay 034cb5c83d feat(media_player): option-gated adb_command service (default off; registered at setup, unregistered at last unload, ADB-down rejection) + redacted entry diagnostics (P4-2a,b) 2026-10-08 11:08:57 +01:00
James Mackay 4222027602 feat(const,config_flow): option-gated adb_command service switch (default off) + curated app directory consts + README service/security notes (P4-2) 2026-10-08 11:07:28 +01:00
James Mackay f4d750ff92 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) 2026-10-08 11:03:28 +01:00
James Mackay 61c7ac99a3 feat(config_flow,media_player): apps name/icon map editor in the options flow (package=name per line, invalid input error) + app_name resolves through CONF_APP_NAME_MAP after the entity/push tiers (P4-1b) 2026-10-08 10:58:04 +01:00
James Mackay 1dbb695cec feat(config_flow): zeroconf discovery step — pre-filled user step from the discovery address, duplicate-MAC abort, IP-change host update + reload for known entries (P4-1a) 2026-10-08 10:57:31 +01:00
James Mackay 8b4fea9a5c feat(const,manifest): zeroconf service type + CONF_APP_NAME_MAP; manifest declares the _androidtvremote2._tcp discovery dependency (P4-1) 2026-10-08 10:57:08 +01:00
James Mackay ce90f59f8e fix(config_flow): no_state_source fires only when none of the three sources is present — ADB-only standalone is valid; strings/docs name all three (F2) 2026-10-08 10:52:44 +01:00
James Mackay 3b8e97fe5b fix(media_player): privacy bare-app identity outranks the channel-title fallback in the foreground tier (F3) 2026-10-08 10:52:24 +01:00
James Mackay 392553ee26 fix(media_player): channel-title fallback gated to the channel app (TIVIMATE_PACKAGE or audio_playback membership) — launcher never shows a channel number (F1) 2026-10-08 10:52:08 +01:00
James Mackay f58be9ed05 docs: state ladder + fallback semantics in README; mediasession2mqtt retirement + universal-player glue appendix in RESEARCH_SHIELD; TiviMate as the documented no-session case (P3-3d) 2026-10-08 10:46:32 +01:00
James Mackay bc457baae7 feat(media_player): foreground tier in the state ladder with app-source now-playing (last-known → last_channel → audio hint), sensor reads the store's foreground priority chain (P3-3a,c) 2026-10-08 10:45:58 +01:00
James Mackay 1f0a4b2021 feat(running_apps): media_dag_running_apps enrichment — TTL-capped last_known_state/last_known_title for apps without a live session (P3-3b) 2026-10-08 10:42:41 +01:00
James Mackay aa4ee7ed62 feat(connection): TTL-filtered last_known_map snapshot of the per-package cache (P3-3b feed) 2026-10-08 10:42:24 +01:00
James Mackay bb2f787fcd test(ha_adb_common): fixture-driven foreground-parser tests — TiviMate capture, R4 launcher, wrap join, topResumed fallback ordering, duplicates tolerated (P3-2a) 2026-10-08 10:39:43 +01:00
James Mackay 8885ddd5b5 feat(connection): ADB foreground fallback on the watchdog cadence when the protocol push is silent (store priority protocol > ADB); audio-playback hint filtered through SYSTEM_MEDIA_PACKAGES (P3-2b,c) 2026-10-08 10:33:38 +01:00
James Mackay f1e49a661b feat(ha_adb_common): foreground parser — mResumedActivity primary with block-join for ActivityRecord wraps, topResumedActivity fallback (R4); no window-focus parsing 2026-10-08 10:32:39 +01:00
James Mackay 34d09e9f70 feat(media_player): app_id/app_name prefer the androidtv entity, fall back to the protocol current_app push when absent/unavailable; manifest androidtvremote2 floor aligned to >=0.2.0 (P3-1b,c) 2026-10-08 10:28:56 +01:00
James Mackay d35b61078d feat(config_flow): standalone setup — androidtv entity optional with no_state_source validation, entry data omits the field for standalone entries, sensor tolerates its absence; migration keeps v1 field / v2 absence valid (P3-1a,d) 2026-10-08 10:28:56 +01:00
James Mackay a65c893339 refactor(connection): public protocol_active accessor replaces the remote entity's private reach-in; media_player module docstring describes the Phase 2 architecture (F7) 2026-10-08 10:12:40 +01:00
James Mackay 5f8c3290cc fix: flow VERSION=2 for new entries; optional ENTER gains the ADB keyevent-66 fallback; CI installs androidtvremote2 for the real-library tests (F4, F5, F6) 2026-10-08 10:12:12 +01:00
James Mackay 8462ad494c fix(connection): ADB outage clears only ADB-derived store fields — protocol push truth survives by TTL (F2) 2026-10-08 10:03:27 +01:00
James Mackay b451638c65 test: command-routing and play_media(channel) orchestration — digit sequence with cancel guard, fallback ordering protocol→ADB→legacy, remote send_command holds/repeats, volume readout, is_on ladder 2026-10-08 09:54:23 +01:00
James Mackay f071d540bd feat(remote): protocol-backed RemoteEntity — SHORT/START_LONG/END_LONG prefixes, num_repeats/delay/hold, activity = current-app push, POWER keys honoring is_on 2026-10-08 09:45:06 +01:00
James Mackay 728b7ccbf9 feat(media_player): protocol-first command routing with ADB + legacy fallbacks, play_media(channel/app/url) per §7 with digit task-cancel guard, volume readout from protocol push, is_on tier in the power ladder 2026-10-08 09:43:34 +01:00
James Mackay 61aca9d413 feat: protocol key/launch command layer — RemoteKeyCode maps, C-LAUNCH app launch, ADB digit keyevent fallback, PlaybackIntent protocol path 2026-10-08 09:41:45 +01:00
James Mackay 906126b0da test(config_flow): protocol cert/PIN pairing, reauth-after-hard-reset, and v1→v2 entry migration (scoped stubs, deterministic PIN/MAC fake client) 2026-10-08 09:36:17 +01:00
James Mackay d57c4eaa40 feat: protocol_settings wiring + InvalidAuth reauth start + entry migration to v2; PLATFORMS gains REMOTE (placeholder platform until t10) 2026-10-08 09:33:19 +01:00
James Mackay b5fc0dacf4 feat(config_flow): remote-protocol cert/PIN pairing step + reauth after device hard reset (shared .storage cert paths; ADB step body byte-intact, routes onward when protocol enabled) 2026-10-08 09:32:27 +01:00
James Mackay e37a3be75f feat(const): protocol config keys + ports; connection manager generates missing certs before protocol start 2026-10-08 09:30:50 +01:00
James Mackay a4e7a285d4 fix(connection): let the library own keep_reconnecting's task — no double scheduling on protocol start/stop 2026-10-08 09:27:50 +01:00
James Mackay 9a1e1300dd test(connection): protocol-transport unit tests — push wiring, InvalidAuth reauth hook, lib-owned keep_reconnecting lifecycle, passive paths, volume normalization 2026-10-08 09:27:38 +01:00
James Mackay 49cf402bbe feat(pyproject): androidtvremote2 dev dependency; manager docstring covers both transports 2026-10-08 09:25:25 +01:00
James Mackay 27cc69b1d3 feat(connection): protocol transport alongside ADB — AndroidTVRemote client, keep_reconnecting ownership, push callbacks into StateStore, InvalidAuth→reauth hook, cert/PIN client factory 2026-10-08 09:25:11 +01:00
James Mackay d07820cde8 test: sound backoff-jitter assertions (per-delay band + non-overlapping growth) and scoped import-stub teardown in the pairing regression tests (F5 residual) 2026-10-08 09:19:20 +01:00
James Mackay 75fff11d04 fix(media_player): ADB-health-aware _attr_available; fetch_media_info dispatches through _track_task (F2, F4) 2026-10-08 09:13:51 +01:00
James Mackay 9c409b3805 fix(connection): reconnect decision closes the failed socket (health_check's documented role) + symmetric backoff-bound test assertion (F3, F5) 2026-10-08 09:13:51 +01:00
James Mackay 24981d84a8 fix(config_flow): pairing step passes test_connect's declared signature (host, port, key_path) — regression tests cover the adb_pair submit path (F1) 2026-10-08 09:13:51 +01:00
James Mackay 48c30463ec fix: await the async ADB poller-factory builder during entry setup (keeps setup non-blocking, no ConfigEntryNotReady) 2026-10-08 09:04:13 +01:00
James Mackay 0da84a5a6e refactor(sensor): Current App sensor falls back to the connection manager StateStore and refreshes on store updates 2026-10-08 09:03:42 +01:00
James Mackay 93b766ae97 refactor(media_player): entity becomes a thin view over the ShieldConnectionManager store; poll loop and socket removed from the entity 2026-10-08 09:03:18 +01:00
James Mackay 0a7dd887d7 refactor(config_flow): ADB pairing step reuses ShieldConnectionManager.test_connect (throwaway one-shot connect) 2026-10-08 08:59:36 +01:00
James Mackay 63f76f5328 feat: entry.runtime_data ShieldConnectionManager lifecycle — passive when ADB unavailable, close on HA stop + unload; adb_session adapter docstring 2026-10-08 08:59:07 +01:00
James Mackay ec2db2ae58 test(connection): manager unit tests with a fake poller — connect/reconnect backoff, TTL expiry, listeners, lifecycle 2026-10-08 08:58:00 +01:00
James Mackay 4779c20407 feat(connection): ShieldConnectionManager + StateStore — persistent socket, watchdog backoff reconnect, TTL expiry, listener API (Phase 1, B1/B5) 2026-10-08 08:54:54 +01:00
James Mackay 2ade5091c4 feat(const): persistent-connection timing constants — watchdog backoff/cap/jitter, StateStore TTL 2026-10-08 08:53:27 +01:00
James Mackay 4269e233b8 feat(adb_client): expose last_dump so consumers can parse the Audio playback section from the same query 2026-10-08 08:53:13 +01:00
James Mackay 37164b6f24 feat: re-export parse_audio_playback + parse_wakefulness from ha_adb_common (additive, keeps hisense_tv consumer parity) 2026-10-08 08:45:06 +01:00
James Mackay a72cf57494 test: poll_running_apps parsing against SYNTHETIC activity-recents fixture (A= and I= patterns; real R6 capture still outstanding) 2026-10-08 08:43:23 +01:00
James Mackay a1e671ac6a fix: track intent-retry tasks in the unload-cancelled task registry so removal can't trigger a shell() auto-reconnect (reviewer F2) 2026-10-08 08:43:23 +01:00
James Mackay b91a4242ba feat(adb_client): exact-value mWakefulness= parse (R5), health_check(), optional media-session truncation (B4); wakefulness fixtures + tests 2026-10-08 08:41:21 +01:00
James Mackay f6a37bdcd5 docs: fold research findings into plan — streaming_shell verified on pinned adb-shell 0.4.4 (C-STREAM), market://launch auto-prefix (C-LAUNCH), channel_up/down keys 2026-10-08 08:40:46 +01:00
James Mackay db8712db1a docs: add RESEARCH_IDEAS.md — community feature requests, androidtvremote2/adb_shell API audits, other-integration patterns, ranked ideas (research task t3) 2026-10-08 08:40:14 +01:00
James Mackay 8f27618e3d feat(media_session): parse PlaybackState position/speed/updated (uptime-ms) + parse_audio_playback section (MVP plan Phase 1, R9/R2) 2026-10-08 08:38:35 +01:00
James Mackay 4702c9534c docs: correct Phase 2 volume expectations — androidtvremote2 has no absolute volume set (core closed it not_planned); VOLUME_STEP|MUTE only, level from push 2026-10-08 08:36:11 +01:00
James Mackay ce82127a40 ci: add lint+test workflow; schedule package constants (TiviMate/Wholphin/system media); drop the stale sibling-checkout dev dependency
Release / release (push) Successful in 4s
Test / test (push) Failing after 6s
2026-10-08 07:49:07 +01:00
James Mackay 6b4227cb40 refactor: centralize ADB host/key resolution in helpers.py; drop the three duplicated resolvers 2026-10-08 07:48:30 +01:00
James Mackay d3afda0a25 fix: make ADB poll failures visible and stop stale-state cling; cancel tracked poll tasks on unload (B2, B5-prep); lint cleanups across daemon modules 2026-10-08 07:45:45 +01:00
James Mackay 359b96ef9c fix: retry playback intents over the command path that originally served them (ADB broadcasts annotate the intent, self-heal retry goes back over ADB) 2026-10-08 07:39:53 +01:00
James Mackay a2f1948a88 test: add firmware capture fixtures and stdlib-level unit tests for parsers, intents, running apps 2026-10-08 07:37:34 +01:00
48 changed files with 8290 additions and 335 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Quality gate: lint + unit tests.
#
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
# vendored ha_adb_common helpers without installing Home Assistant — see
# tests/conftest.py. androidtvremote2 is installed so the real-library
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
# firmware captures live in tests/fixtures
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
name: Test
on:
push:
branches: [master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install test tooling
run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2
- name: Lint
run: ruff check custom_components tests
- name: Unit tests
run: python3 -m pytest tests -q
+62
View File
@@ -52,6 +52,68 @@ Changes inherited from upstream still apply in 0.1.x code: polling is timer-base
the ADB connection lives inside the media player entity, and fallback app state the ADB connection lives inside the media player entity, and fallback app state
arrives in Phase 3. arrives in Phase 3.
## State ladder and fallback semantics (Phase 3)
The media player state is decided top-down; each tier serves only when the
ones above it are silent. Both transports live in the entry's
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
remote protocol is the fast push/command path:
1. **Plug off** — the power switch says `off` → `off`.
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
4. **Intent** — a just-sent command's target state, self-healing against
reality within 30 s (retry goes over the transport that served it).
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
6. **ADB media session** — `playing` / `paused` for apps that publish a
session (foreground app preferred).
7. **Foreground fallback** — an app is open (`current_app` protocol push,
then the ADB `mResumedActivity` parse) but publishes no media session →
`on`. Its identity comes from the TTL-capped last-known-state cache, the
last channel entered, or the zero-cost "audio playback" hint
(`media recently active`) — never resurrecting `playing` past the 60 s
state TTL.
8. **On** — powered, awake, nothing more specific.
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
the audio hint are what keep it visible.
**Standalone setup** accepts any one of the three state sources — the Android
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
media-session mode (ADB enabled + host). The `no_state_source` form error
fires only when none of the three is present; ADB-only setups are valid.
### Channel entry options
The options flow exposes the §7 channel behavior the integration replicates:
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
**ENTER press** after the digits (off by default — TiviMate's validated
behavior). Both are consumed by `play_media` with
`media_content_type: channel`.
### Raw `adb_command` service (opt-in)
When enabled in the integration's options (`adb_command_enabled`, **default
off**), the integration registers the `nvidia_shield.adb_command` service:
send a raw ADB shell command to the entry's persistent connection
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
entity is optional when exactly one entry provides the service). This is the
same security posture as the Home Assistant core `androidtv` integration: a
documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down.
### Migration from mediasession2mqtt + universal-player glue
The `mediasession2mqtt` bridge sensors and the universal media player YAML
templates the fork replaces are retired by the ladder above: the media player
entity already carries the DAG attributes (`media_dag_running_apps` with
foreground/last-known enrichment, `media_dag_now_playing` with its source
tag) that media-zone orchestration consumed from those sensors. See the
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
retirement mapping and the universal-player glue it replaces.
## Releases ## Releases
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`). - **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
+133 -4
View File
@@ -1,16 +1,72 @@
"""The Nvidia Shield integration.""" """The Nvidia Shield integration.
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
socket and all ADB-derived state live there, not on the entities. Entities are
thin callback-views over the manager's StateStore.
ADB is optional: a failed/absent ADB connection never blocks setup (no
ConfigEntryNotReady) — the manager retries in the background, or stays
passive until the user pairs via the Pair ADB button.
"""
import logging import logging
import os
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN from .adb_session import AdbMediaSessionPoller
from .connection import ShieldConnectionManager
from .const import (
CONF_ADB_ENABLED,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
DOMAIN,
)
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON] PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
"""Poller factory when ADB is configured AND usable today, else None.
Returning None leaves the manager passive: the entry still sets up, the
entities simply see no ADB state. The Pair ADB button / reconfigure
triggers a reload that re-evaluates this.
"""
if not entry.options.get(CONF_ADB_ENABLED, False):
return None
adb = resolve_adb_config(hass, entry)
if adb is None:
_LOGGER.warning(
"ADB enabled but no host resolvable for entry %s — continuing "
"without ADB state.",
entry.title,
)
return None
# Only auto-connect when a key already exists — an ADB handshake against
# an unpaired device is pointless noise (pairing happens via the Pair ADB
# button / config flow, untouched).
key_path_ready = await hass.async_add_executor_job(
os.path.exists, adb.key_path
)
if not key_path_ready:
_LOGGER.info(
"ADB key not found for entry %s — skipping auto-connect. "
"Use the Pair ADB button or reconfigure the integration.",
entry.title,
)
return None
return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -18,6 +74,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry hass.data[DOMAIN][entry.entry_id] = entry
manager = ShieldConnectionManager(
hass,
entry,
poller_factory=await _build_poller_factory(hass, entry),
protocol_settings=await _build_protocol_settings(hass, entry),
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
)
entry.runtime_data = manager
await manager.async_start()
# Close the persistent socket on Home Assistant stop as well as unload.
async def _close_on_hass_stop(_event) -> None:
await manager.async_stop()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
@@ -25,11 +99,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
"""Manager protocol_settings when the transport is enabled & resolvable.
Falls back to the androidtv-remote host discovery (the ADB host fallback)
until the user pairs, after which the explicit protocol host persists.
"""
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
return None
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host:
_LOGGER.info(
"Protocol enabled for entry %s but no host resolvable — continuing "
"without the remote-protocol transport. Pair via the flow.",
entry.title,
)
return None
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
return {
"host": host,
"client_name": entry.title or "Home Assistant",
"certfile": certfile,
"keyfile": keyfile,
}
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate v1 entries to v2 (adds the remote-protocol fields).
ADB options carry over untouched; the ADB-host discovery fallback keeps
serving as the protocol host until pairing populates it. Downgrades
(version > 2) are rejected so HA offers a rollback.
"""
if config_entry.version > 2:
return False
if config_entry.version < 2:
options = {**config_entry.options}
options.setdefault(CONF_PROTOCOL_ENABLED, False)
options.setdefault(CONF_PROTOCOL_HOST, "")
hass.config_entries.async_update_entry(
config_entry, options=options, version=2
)
_LOGGER.info(
"Migrated %s config entry to version 2", config_entry.title
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
manager = getattr(entry, "runtime_data", None)
if manager is not None:
await manager.async_stop()
entry.runtime_data = None
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok
+24 -5
View File
@@ -1,15 +1,24 @@
"""ADB media session reader for NVIDIA Shield. """ADB session adapter for NVIDIA Shield.
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific Since Phase 1 this module is a thin adapter on top of ha_adb_common:
`poll()` method that calls wake check + media session in sequence.
- ``ensure_adb_key`` (re-exported here for the config flow / Pair ADB button)
does the one-time key-material setup — the pairing flow stays untouched.
- ``AdbMediaSessionPoller`` remains the protocol-level ADB client used by the
ShieldConnectionManager (connection.py) and one-shot pairing connects. The
polling loop itself lives in the connection manager, not here.
Re-exports MediaSessionInfo and ensure_adb_key for existing importers. Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
""" """
import logging import logging
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key from .ha_adb_common import (
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export) AdbClientBase,
MediaSessionInfo, # noqa: F401 (re-export)
parse_foreground,
)
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
prefer its media session over stale sessions from other apps. prefer its media session over stale sessions from other apps.
""" """
return await self.poll_media_sessions(foreground_package) return await self.poll_media_sessions(foreground_package)
async def poll_foreground(self) -> str | None:
"""Foreground package via `dumpsys activity activities` (RESEARCH R4).
The grep keeps the dump tiny; the parser handles the R4 line wrap.
"""
dump = await self.shell("dumpsys activity activities 2>/dev/null | grep -iE 'resumed'")
if not dump:
return None
return parse_foreground(dump)
+9 -23
View File
@@ -5,7 +5,6 @@ import logging
from homeassistant.components.button import ButtonEntity from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -18,6 +17,7 @@ from .const import (
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DOMAIN, DOMAIN,
) )
from .helpers import discover_adb_host
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -52,29 +52,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
model="Shield TV", model="Shield TV",
) )
def _resolve_host(self) -> str | None:
"""Resolve ADB host from config or Android TV entity."""
host = self._entry.options.get(CONF_ADB_HOST)
if host:
return host
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
if not atv_entity:
return None
registry = er.async_get(self.hass)
entity_entry = registry.async_get(atv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def async_press(self) -> None: async def async_press(self) -> None:
"""Attempt ADB connection. The Shield will show a pairing prompt.""" """Attempt ADB connection. The Shield will show a pairing prompt."""
host = self._resolve_host() host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host: if not host:
@@ -85,7 +67,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
ensure_adb_key, self.hass.config.config_dir ensure_adb_key, self.hass.config.config_dir
) )
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port) _LOGGER.info(
"Attempting ADB pairing with %s:%d — approve the prompt on the Shield",
host,
port,
)
poller = AdbMediaSessionPoller(host, port, key_path) poller = AdbMediaSessionPoller(host, port, key_path)
if await poller.connect(): if await poller.connect():
+374 -47
View File
@@ -1,29 +1,42 @@
"""Config flow for Nvidia Shield integration.""" """Config flow for Nvidia Shield integration."""
import contextlib
import logging import logging
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import selector
from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .adb_session import ensure_adb_key
from .connection import ShieldConnectionManager
from .const import ( from .const import (
ATTR_MEDIA_DAG_INPUT_MAP, ATTR_MEDIA_DAG_INPUT_MAP,
CHANNEL_DIGIT_DELAY,
CONF_ADB_COMMAND_ENABLED,
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST, CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
) )
from .helpers import discover_adb_host, protocol_cert_paths
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -69,6 +82,81 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None:
return None return None
def _app_map_to_text(app_map: dict, icon_map: dict | None = None) -> str:
"""Serialize the apps map to one ``package=name[|icon]`` per line."""
icon_map = icon_map or {}
lines = []
for pkg, name in app_map.items():
icon = icon_map.get(pkg)
lines.append(f"{pkg}={name}|{icon}" if icon else f"{pkg}={name}")
return "\n".join(lines)
def _app_map_from_text(
text: str, icons: dict[str, str] | None = None
) -> dict[str, str] | None:
"""Parse one ``package=name[|icon]`` per line; None on malformed input.
The optional second column (a picture URL or entity id) feeds
entity_picture through CONF_APP_ICON_MAP; single-column maps stay
valid. Malformed icon text (spaces) invalidates the row the same way
a malformed name does.
"""
icons = icons if icons is not None else {}
app_map: dict[str, str] = {}
for line in text.split("\n"):
line = line.strip()
if not line:
continue
if "=" not in line:
return None
package, value = line.split("=", 1)
parts = value.strip().split("|", 1)
name = parts[0].strip()
icon = parts[1].strip() if len(parts) > 1 else ""
package = package.strip()
name = name.strip()
if not package or not name or " " in package:
return None
if icon:
if " " in icon:
return None
icons[package] = icon
app_map[package] = name
return app_map
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — the androidtv entity is optional (standalone).
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
"""
defaults = defaults or {}
schema_dict = {
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
}
# If there are any receivers with DAG input maps, show a text field
# for receiver input (will be a dropdown in options once receiver is set)
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
if "host" in defaults:
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str
return schema_dict
def _build_selectors(): def _build_selectors():
"""Build reusable selector instances.""" """Build reusable selector instances."""
return { return {
@@ -90,18 +178,44 @@ def _build_selectors():
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield.""" """Handle a config flow for Nvidia Shield."""
VERSION = 1 VERSION = 2
def __init__(self): def __init__(self):
self._user_data = {} self._user_data = {}
self._protocol_client = None
self._reauth_entry = None
# Zeroconf discovery state (P4-1)
self._discovered_host: str | None = None
self._discovered_name: str | None = None
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Step 1: Basic setup.""" """Step 1: Basic setup.
Standalone setup (Phase 3): any one of the three state sources is
enough — the Android TV Remote entity, the Nvidia Remote Protocol
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
``no_state_source`` error fires only when none of them is present.
"""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY] androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
has_source = (
bool(androidtv_entity)
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
or bool(user_input.get(CONF_ADB_ENABLED))
)
if not has_source:
errors["base"] = "no_state_source"
sel = _build_selectors()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(_user_schema_dict(sel)),
errors=errors,
)
if androidtv_entity:
await self.async_set_unique_id(androidtv_entity) await self.async_set_unique_id(androidtv_entity)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@@ -109,7 +223,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY) jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
if not jellyfin: if not jellyfin:
device_name = user_input.get("name", DEFAULT_NAME) device_name = user_input.get("name", DEFAULT_NAME)
atv_state = self.hass.states.get(androidtv_entity) atv_state = (
self.hass.states.get(androidtv_entity)
if androidtv_entity
else None
)
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "") atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
for name in [device_name, atv_name]: for name in [device_name, atv_name]:
@@ -123,7 +241,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = { self._user_data = {
"name": user_input.get("name", DEFAULT_NAME), "name": user_input.get("name", DEFAULT_NAME),
CONF_ANDROIDTV_ENTITY: androidtv_entity, CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
CONF_JELLYFIN_ENTITY: jellyfin or "", CONF_JELLYFIN_ENTITY: jellyfin or "",
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""), CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""), CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
@@ -132,34 +250,29 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""), CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
} }
# If ADB is enabled, go to pairing step # If ADB is enabled, go to pairing step
if self._user_data[CONF_ADB_ENABLED]: if self._user_data[CONF_ADB_ENABLED]:
return await self.async_step_adb_pair() return await self.async_step_adb_pair()
if self._user_data[CONF_PROTOCOL_ENABLED]:
return await self.async_step_protocol_pair()
return self._create_entry() return self._create_entry()
sel = _build_selectors() sel = _build_selectors()
schema_dict = { defaults = {}
vol.Required("name", default=DEFAULT_NAME): str, if self._discovered_host:
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"], defaults = {
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"], "name": self._discovered_name or DEFAULT_NAME,
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"], "host": self._discovered_host,
} }
schema_dict = _user_schema_dict(sel, defaults)
# If there are any receivers with DAG input maps, show a text field
# for receiver input (will be a dropdown in options once receiver is set)
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
@@ -167,12 +280,68 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_zeroconf(self, discovery_info):
"""Zeroconf discovery of the Android TV remote service (P4-1).
Core androidtv_remote pattern, simplified: the discovery address
becomes the pre-filled protocol/ADB host on the user step; a known
MAC (from the service properties) aborts duplicate setup and
updates the existing entry's hosts on an IP change.
"""
host = str(
getattr(discovery_info, "ip_address", None)
or getattr(discovery_info, "host", None)
or ""
).strip()
if not host:
return self.async_abort(reason="zeroconf_no_host")
self._discovered_host = host
props = getattr(discovery_info, "properties", None) or {}
self._discovered_name = props.get("name")
mac = props.get("mac") or props.get("unique_id")
if mac:
await self.async_set_unique_id(mac)
for entry in self._async_current_entries():
if getattr(entry, "unique_id", None) != mac:
continue
# Known device — per-host updates (t21 F2): each host is
# tracked independently against the discovery address.
# - a blank (auto-detect) host self-heals from the address;
# - a stale protocol host is the IP change and is updated;
# - a deliberately-different ADB host (an explicit override
# that differs from both the address and the stored
# protocol host) is never clobbered.
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
adb_host = entry.options.get(CONF_ADB_HOST, "")
options = dict(entry.options)
if protocol_host != host:
options[CONF_PROTOCOL_HOST] = host
if not adb_host or adb_host == protocol_host:
# blank (self-heal) or auto-synced alongside the protocol
# host — safe to follow the device address on discovery
options[CONF_ADB_HOST] = host
if options != entry.options:
self.hass.config_entries.async_update_entry(
entry, options=options
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="already_configured")
self._abort_if_unique_id_configured()
# First-time setup: pre-filled user step
return await self.async_step_user()
def _create_entry(self): def _create_entry(self):
"""Create the config entry from stored user data.""" """Create the config entry from stored user data."""
d = self._user_data d = self._user_data
entry_data = {}
if d.get(CONF_ANDROIDTV_ENTITY):
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
return self.async_create_entry( return self.async_create_entry(
title=d["name"], title=d["name"],
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]}, data=entry_data,
options={ options={
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""), CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""), CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
@@ -182,28 +351,131 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""), CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
}, },
) )
async def _async_after_pairing(self):
"""Route after the pairing steps: protocol next, else create the entry."""
if self._user_data.get(CONF_PROTOCOL_ENABLED):
return await self.async_step_protocol_pair()
return self._create_entry()
def _protocol_host(self) -> str | None:
"""Protocol host: explicit override, else the ADB host fallback."""
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
async def _start_protocol_pairing(self):
"""Build a throwaway protocol client and make the Shield show its PIN.
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
helpers.py pattern) and are shared between pairing and runtime.
"""
host = self._protocol_host()
if not host:
return None
certfile, keyfile = await self.hass.async_add_executor_job(
protocol_cert_paths, self.hass
)
client = ShieldConnectionManager.build_protocol_client(
host,
client_name=self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
certfile=certfile,
keyfile=keyfile,
)
await client.async_generate_cert_if_missing()
await client.async_start_pairing()
return client
async def async_step_protocol_pair(self, user_input=None):
"""Pair with the remote protocol — the PIN is displayed on the Shield."""
errors = {}
if user_input is None:
# First visit: proactively start pairing so the PIN shows on the
# Shield while the form is open.
client = await self._start_protocol_pairing()
if client is None:
errors["base"] = "protocol_no_host"
else:
self._protocol_client = client
else:
client = self._protocol_client
code = user_input.get(CONF_PAIRING_CODE, "")
if client is None or not code:
errors["base"] = "protocol_invalid_code"
else:
try:
await client.async_finish_pairing(code)
except Exception:
errors["base"] = "protocol_pair_failed"
else:
try:
_name, mac = await client.async_get_name_and_mac()
except Exception:
mac = None
with contextlib.suppress(Exception):
client.disconnect()
self._protocol_client = None
if mac:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if self._reauth_entry is not None:
return self._finish_reauth()
return self._create_entry()
return self.async_show_form(
step_id="protocol_pair",
data_schema=vol.Schema({
vol.Required(CONF_PAIRING_CODE): str,
}),
errors=errors,
)
async def async_step_reauth(self, entry_data):
"""Device hard reset invalidated our certificates (review trigger)."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context.get("entry_id")
)
if self._reauth_entry is None:
return self.async_abort(reason="reauth_entry_missing")
return self.async_show_form(
step_id="reauth_confirm", data_schema=vol.Schema({})
)
async def async_step_reauth_confirm(self, user_input=None):
"""Re-pair after a device hard reset, then reload the entry."""
if user_input is not None:
entry = self._reauth_entry
self._user_data = {
"name": entry.title,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
}
return await self.async_step_protocol_pair() # first visit re-pairs
return self.async_show_form(
step_id="reauth_confirm", data_schema=vol.Schema({})
)
def _finish_reauth(self):
"""Reauth pairing done: update options, schedule the reload, abort."""
entry = self._reauth_entry
self.hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
},
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
self._reauth_entry = None
return self.async_abort(reason="reauth_successful")
def _resolve_adb_host(self) -> str | None: def _resolve_adb_host(self) -> str | None:
"""Resolve ADB host from explicit config or the Android TV entity.""" """Resolve ADB host from explicit config or the Android TV entity."""
host = self._user_data.get(CONF_ADB_HOST) return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
if host: self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
return host )
atv_entity = self._user_data.get(CONF_ANDROIDTV_ENTITY)
if not atv_entity:
return None
registry = er.async_get(self.hass)
entity_entry = registry.async_get(atv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def async_step_adb_pair(self, user_input=None): async def async_step_adb_pair(self, user_input=None):
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield.""" """Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
@@ -220,12 +492,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
ensure_adb_key, self.hass.config.config_dir ensure_adb_key, self.hass.config.config_dir
) )
poller = AdbMediaSessionPoller(host, port, key_path) connected = await ShieldConnectionManager.test_connect(
connected = await poller.connect() host, port, key_path
await poller.disconnect() )
if connected: if connected:
return self._create_entry() return await self._async_after_pairing()
else: else:
errors["base"] = "adb_failed" errors["base"] = "adb_failed"
@@ -250,13 +521,53 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None): async def async_step_init(self, user_input=None):
"""Single-page options.""" """Single-page options."""
errors = {}
if user_input is not None: if user_input is not None:
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
if delay is not None:
try:
delay = float(delay)
except (TypeError, ValueError):
errors["base"] = "invalid_digit_delay"
delay = None
if delay is not None and not (0.05 <= delay <= 1.0):
errors["base"] = "invalid_digit_delay"
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
icons: dict[str, str] = {}
if app_map_text is None:
app_map = self._options.get(CONF_APP_NAME_MAP, {})
elif app_map_text.strip() == "":
app_map = {}
else:
app_map = _app_map_from_text(app_map_text, icons)
if app_map is None:
errors["base"] = "invalid_app_map"
if not errors:
if delay is not None:
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
self._options.update(user_input) self._options.update(user_input)
self._options[CONF_APP_NAME_MAP] = app_map
# P5-2: the icon column — the editor is the source of
# truth, so a save without icons clears them (and a
# single-column map stays valid).
self._options[CONF_APP_ICON_MAP] = icons
return self.async_create_entry(title="", data=self._options) return self.async_create_entry(title="", data=self._options)
sel = _build_selectors() sel = _build_selectors()
schema_dict = {} schema_dict = {}
# Apps name/icon map editor (P4-1, core androidtv_remote options
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
# the optional icon column).
current_map = self._options.get(CONF_APP_NAME_MAP, {})
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
if current_map:
app_map_default = _app_map_to_text(current_map, current_icons)
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
else:
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
# Jellyfin # Jellyfin
jf = self._options.get(CONF_JELLYFIN_ENTITY) jf = self._options.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
@@ -271,6 +582,21 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv) recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
schema_dict[recv_key] = sel["receiver"] schema_dict[recv_key] = sel["receiver"]
# P5-2: channel digit behavior (the user-validated §7 flow)
current_delay = self._options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY, default=current_delay)] = str
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
# P5-3: the live events push is opt-in (default off; the user
# tests stability on their device and reports)
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False)
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
# P4-2a: the raw adb_command service is opt-in (default off)
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False)
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
# Receiver input — dropdown from DAG if receiver is set, text otherwise # Receiver input — dropdown from DAG if receiver is set, text otherwise
current_input = self._options.get(CONF_RECEIVER_INPUT, "") current_input = self._options.get(CONF_RECEIVER_INPUT, "")
receiver = self._options.get(CONF_RECEIVER_ENTITY) receiver = self._options.get(CONF_RECEIVER_ENTITY)
@@ -324,4 +650,5 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema_dict), data_schema=vol.Schema(schema_dict),
errors=errors,
) )
File diff suppressed because it is too large. Load diff
+108 -1
View File
@@ -22,17 +22,124 @@ CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
CONF_ADB_HOST = "adb_host" CONF_ADB_HOST = "adb_host"
CONF_ADB_PORT = "adb_port" CONF_ADB_PORT = "adb_port"
# Remote-protocol transport (androidtvremote2) — Phase 2
CONF_PROTOCOL_ENABLED = "protocol_enabled"
CONF_PROTOCOL_HOST = "protocol_host"
CONF_PAIRING_CODE = "pairing_code"
DEFAULT_PROTOCOL_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467
ADB_POLL_INTERVAL = 10 # seconds ADB_POLL_INTERVAL = 10 # seconds
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
DEFAULT_ADB_PORT = 5555 DEFAULT_ADB_PORT = 5555
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
# fields expire (B5) instead of clinging to stale values.
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
DEFAULT_NAME = "Nvidia Shield" DEFAULT_NAME = "Nvidia Shield"
# Emotiva DAG attribute we read to get available inputs # Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map" ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
# Known Jellyfin app package IDs # Known Jellyfin app package IDs (used to route Jellyfin-family clients
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
JELLYFIN_APP_IDS = [ JELLYFIN_APP_IDS = [
"org.jellyfin.androidtv", "org.jellyfin.androidtv",
"org.jellyfin.mobile", "org.jellyfin.mobile",
"com.github.damontecres.wholphin",
]
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
# HA service kind → protocol keycode string.
PROTOCOL_MEDIA_KEYS = {
"media_play": "KEYCODE_MEDIA_PLAY",
"media_pause": "KEYCODE_MEDIA_PAUSE",
"media_stop": "KEYCODE_MEDIA_STOP",
"media_next_track": "KEYCODE_MEDIA_NEXT",
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
}
PROTOCOL_VOLUME_KEYS = {
"volume_up": "KEYCODE_VOLUME_UP",
"volume_down": "KEYCODE_VOLUME_DOWN",
"volume_mute": "KEYCODE_VOLUME_MUTE",
}
PROTOCOL_REMOTE_KEYS = {
**PROTOCOL_VOLUME_KEYS,
"play": "KEYCODE_MEDIA_PLAY",
"pause": "KEYCODE_MEDIA_PAUSE",
"stop": "KEYCODE_MEDIA_STOP",
"up": "KEYCODE_DPAD_UP",
"down": "KEYCODE_DPAD_DOWN",
"left": "KEYCODE_DPAD_LEFT",
"right": "KEYCODE_DPAD_RIGHT",
"enter": "KEYCODE_DPAD_CENTER",
"home": "KEYCODE_HOME",
"back": "KEYCODE_BACK",
"menu": "KEYCODE_MENU",
"search": "KEYCODE_SEARCH",
"power": "KEYCODE_POWER",
"channel_up": "KEYCODE_CHANNEL_UP",
"channel_down": "KEYCODE_CHANNEL_DOWN",
}
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
# (replicates HA core androidtv_remote's verified behavior).
# Zeroconf discovery (Phase 4): the Android TV remote service type.
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
# P4-1 apps name/icon map (options flow editor): package → friendly name,
# consumed by app_name after the androidtv-entity and protocol-push tiers.
CONF_APP_NAME_MAP = "app_name_map"
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
# entity_picture when no Jellyfin session picture is live.
CONF_APP_ICON_MAP = "app_icon_map"
# P5-1: device manifest label cache TTL (long — labels change with app
# updates, not per poll).
APP_LABEL_TTL = 24 * 3600
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
SERVICE_ADB_COMMAND = "adb_command"
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
# (foreground AppState fast-path). Default OFF; the user flips it on and
# reports stability on their device.
CONF_LOGCAT_EVENTS = "logcat_events"
# P5-3: duplicate START for the same package inside this window is not
# re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c BrowseMedia app directory fallback when the options map is empty
# (curated; the packages are the plan's reference apps).
DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate",
"com.github.damontecres.wholphin": "Wholphin",
"com.netflix.ninja": "Netflix",
"com.google.android.youtube.tv": "YouTube",
}
CHANNEL_DIGIT_DELAY = 0.1
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
# not publish a MediaSession on Shield Android 11 — it is the headline
# Phase-3 fallback app.
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
# Packages to ignore when parsing foreground/audio-playback identities
SYSTEM_MEDIA_PACKAGES = [
"com.google.android.apps.mediashell", # Chromecast receiver process
"com.android.systemui",
"com.android.tv.settings",
"com.nvidia.diagtools",
] ]
@@ -0,0 +1,78 @@
"""Entry diagnostics for Nvidia Shield (P4-2b, t21 F1 platform-module move).
HA discovers ``async_get_config_entry_diagnostics`` through the integration's
``diagnostics`` platform module — a placement inside media_player.py never
serves in a real install (t20 finding F1).
The payload: redacted entry config (data + options), the manager's StateStore
snapshot via the public ``store_snapshot`` accessor, and both transports'
health. Cert paths are kept readable; key material / pairing secrets never
exist in stored state, and any key carrying a secret-shaped name is redacted
defensively.
"""
import time
from dataclasses import asdict, is_dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .connection import ShieldConnectionManager
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
_REDACTED = "**REDACTED**"
def _redact(obj):
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
if isinstance(obj, dict):
return {
key: (
_REDACTED
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
else _redact(value)
)
for key, value in obj.items()
}
if isinstance(obj, list):
return [_redact(item) for item in obj]
return obj
def _store_snapshot(manager) -> dict[str, dict]:
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
snapshot = {}
for name, (stamp, value) in manager.store.values_stamped().items():
if is_dataclass(value) and not isinstance(value, type):
value = asdict(value)
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
return snapshot
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict:
"""Entry diagnostics: redacted config + store snapshot + transport health."""
manager = getattr(entry, "runtime_data", None)
payload: dict = {
"entry": {
"data": dict(getattr(entry, "data", {}) or {}),
"options": dict(getattr(entry, "options", {}) or {}),
},
"store": {},
"transport": {},
}
if isinstance(manager, ShieldConnectionManager):
payload["store"] = manager.store_snapshot()
payload["transport"] = {
"adb": {"connected": manager.connected, "awake": manager.awake},
"protocol": {
"active": manager.protocol_active,
"available": manager.is_available,
"is_on": manager.is_on,
},
}
return _redact(payload)
__all__ = ["async_get_config_entry_diagnostics"]
@@ -1,6 +1,7 @@
"""Shared ADB utilities for Home Assistant custom integrations.""" """Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase from .adb_client import AdbClientBase, parse_wakefulness
from .app_label import parse_application_label
from .const import ( from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING, ATTR_MEDIA_DAG_NOW_PLAYING,
@@ -9,9 +10,16 @@ from .const import (
ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS, ATTR_MEDIA_DAG_RUNNING_APPS,
) )
from .foreground import parse_foreground
from .intent import PlaybackIntent from .intent import PlaybackIntent
from .keys import ensure_adb_key from .keys import ensure_adb_key
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session from .logcat_events import foreground_from_logcat
from .media_session import (
MediaSessionInfo,
parse_all_media_sessions,
parse_audio_playback,
parse_media_session,
)
from .running_apps import running_apps_from_sessions, running_apps_from_tasks from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [ __all__ = [
@@ -26,6 +34,11 @@ __all__ = [
"PlaybackIntent", "PlaybackIntent",
"ensure_adb_key", "ensure_adb_key",
"parse_all_media_sessions", "parse_all_media_sessions",
"parse_application_label",
"parse_audio_playback",
"parse_foreground",
"foreground_from_logcat",
"parse_wakefulness",
"parse_media_session", "parse_media_session",
"running_apps_from_sessions", "running_apps_from_sessions",
"running_apps_from_tasks", "running_apps_from_tasks",
@@ -3,7 +3,9 @@
Subclasses add device-specific polling (media sessions, wake state, etc.). Subclasses add device-specific polling (media sessions, wake state, etc.).
""" """
import contextlib
import logging import logging
import re
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES} ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
# Anything else is "unknown" — never silently reported as awake or asleep.
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
def parse_wakefulness(text: str | None) -> bool | None:
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
Exact-value parse, no substring matching: only the ``mWakefulness=``
assignment counts — ``mWakefulnessChanging=false`` and
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
as "unknown", never as awake or asleep.
"""
if not text:
return None
for line in text.splitlines():
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
if match:
value = match.group(1)
if value == "Awake":
return True
if value in _WAKEFULNESS_ASLEEP_VALUES:
return False
return None
return None
class AdbClientBase: class AdbClientBase:
"""Base ADB client with connection management and common commands.""" """Base ADB client with connection management and common commands."""
@@ -43,12 +72,28 @@ class AdbClientBase:
# Media session state # Media session state
self._last_info: MediaSessionInfo | None = None self._last_info: MediaSessionInfo | None = None
self._last_dump: str | None = None
self._all_sessions: list[MediaSessionInfo] = [] self._all_sessions: list[MediaSessionInfo] = []
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._connected return self._connected
@property
def config(self) -> dict:
"""Public connection config: {host, port, key_path} (t27 F-NEW3).
Replaces the private-attr reach-ins for secondary streams — callers
(the dedicated logcat stream) share the signer material without
touching the poller's socket or its private attributes.
"""
return {
"host": self._host,
"port": self._port,
"key_path": self._key_path,
}
@property @property
def last_info(self) -> MediaSessionInfo | None: def last_info(self) -> MediaSessionInfo | None:
return self._last_info return self._last_info
@@ -60,6 +105,7 @@ class AdbClientBase:
def clear_info(self) -> None: def clear_info(self) -> None:
"""Clear the cached media session info.""" """Clear the cached media session info."""
self._last_info = None self._last_info = None
self._last_dump = None
self._all_sessions = [] self._all_sessions = []
async def connect(self) -> bool: async def connect(self) -> bool:
@@ -90,17 +136,14 @@ class AdbClientBase:
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Disconnect from the device.""" """Disconnect from the device."""
if self._device: if self._device:
try: with contextlib.suppress(Exception):
await self._device.close() await self._device.close()
except Exception:
pass
self._device = None self._device = None
self._connected = False self._connected = False
async def shell(self, cmd: str) -> str | None: async def shell(self, cmd: str) -> str | None:
"""Run a shell command, reconnecting if needed.""" """Run a shell command, reconnecting if needed."""
if not self._connected or not self._device: if (not self._connected or not self._device) and not await self.connect():
if not await self.connect():
return None return None
try: try:
return await self._device.shell(cmd) return await self._device.shell(cmd)
@@ -135,6 +178,24 @@ class AdbClientBase:
return True return True
return False return False
async def send_digit_keyevent(self, digit: str) -> bool:
"""Send a channel digit via `input keyevent` (KEYCODE_0..9 = 7..16)."""
if not digit.isdigit() or len(digit) != 1:
_LOGGER.warning("Not a single digit: %s", digit)
return False
result = await self.shell(f"input keyevent {7 + int(digit)}")
if result is not None:
_LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit)
return result is not None
async def send_enter_keyevent(self) -> bool:
"""Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER
fallback for the §7 channel sequence."""
result = await self.shell("input keyevent 66")
if result is not None:
_LOGGER.debug("ADB ENTER keyevent (66)")
return result is not None
async def bring_to_foreground(self, package: str) -> bool: async def bring_to_foreground(self, package: str) -> bool:
"""Bring an app to the foreground using monkey command.""" """Bring an app to the foreground using monkey command."""
result = await self.shell( result = await self.shell(
@@ -145,27 +206,60 @@ class AdbClientBase:
return result is not None return result is not None
async def is_awake(self) -> bool | None: async def is_awake(self) -> bool | None:
"""Check if the device screen is on / awake via ADB.""" """Check if the device screen is on / awake via ADB.
Exact-value mWakefulness= parse (R5); None means "unknown".
"""
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness") result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
if result: return parse_wakefulness(result)
return "Awake" in result
return None
# -- Media session polling -- # -- Media session polling --
async def poll_media_sessions( async def poll_media_sessions(
self, foreground_package: str | None = None, head_lines: int = 200 self, foreground_package: str | None = None, head_lines: int | None = 200
) -> MediaSessionInfo | None: ) -> MediaSessionInfo | None:
"""Query media sessions. Stores all sessions and returns the best match.""" """Query media sessions. Stores all sessions and returns the best match.
dump = await self.shell(
f"dumpsys media_session 2>/dev/null | head -{head_lines}" Args:
) foreground_package: Prefer the session of the foreground app.
head_lines: Truncate the dump to N lines (the historical default
of 200). Pass None for the full dump — the ShieldConnection
manager does so, since the cut silently drops sessions below
it (plan blocker B4).
"""
command = "dumpsys media_session 2>/dev/null"
if head_lines is not None:
command += f" | head -{head_lines}"
dump = await self.shell(command)
self._last_dump = dump if dump is not None else None
if dump is None: if dump is None:
return None return None
self._all_sessions = parse_all_media_sessions(dump) self._all_sessions = parse_all_media_sessions(dump)
self._last_info = parse_media_session(dump, foreground_package) self._last_info = parse_media_session(dump, foreground_package)
return self._last_info return self._last_info
@property
def last_dump(self) -> str | None:
"""Raw output of the last media-session query (or None if it failed).
Shared consumers (the ShieldConnectionManager) parse the "Audio
playback" section from this same dump — no extra round trip.
"""
return self._last_dump
# -- Health check --
async def health_check(self) -> bool:
"""Verify the connection is actually usable with a cheap shell round trip.
Returns True only when the device answers the trivial ``echo ok`` with
the expected body — a stronger signal than the ``connected`` flag,
which can lag behind reality (the watchdog uses this to fire a
reconnect instead of trusting a dead socket).
"""
result = await self.shell("echo ok")
return result is not None and result.strip() == "ok"
# -- Running apps (recent visible tasks) -- # -- Running apps (recent visible tasks) --
async def poll_running_apps(self) -> list[dict]: async def poll_running_apps(self) -> list[dict]:
@@ -182,12 +276,11 @@ class AdbClientBase:
if not result: if not result:
return [] return []
import re
apps = [] apps = []
seen = set() seen = set()
# System packages to exclude # System packages to exclude
_EXCLUDED = { excluded = {
"com.android.systemui", "com.android.systemui",
"com.android.tv.settings", "com.android.tv.settings",
"com.nvidia.diagtools", "com.nvidia.diagtools",
@@ -206,7 +299,7 @@ class AdbClientBase:
match = re.search(r"I=(\S+)/", stripped) match = re.search(r"I=(\S+)/", stripped)
if match: if match:
pkg = match.group(1) pkg = match.group(1)
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."): if pkg not in seen and pkg not in excluded and not pkg.startswith("."):
seen.add(pkg) seen.add(pkg)
apps.append({"package": pkg}) apps.append({"package": pkg})
@@ -0,0 +1,30 @@
"""App friendly-name resolution from the package manifest (P5-1).
Parses `pm dump <package>` output for the ``application-label`` line — the
default-locale variant; localized suffixed variants
(``application-label-<lang>:``) are skipped, and multi-label blocks across
locales are tolerated (first default-locale label wins), mirroring the
tolerant pattern of the androidtv-adb core helper.
"""
import re
_APPLICATION_LABEL_RE = re.compile(
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
)
def parse_application_label(dump: str) -> str | None:
"""The package's default-locale application label, or None.
Returns None on empty/failed dumps too — callers fall through to the
next name tier.
"""
if not dump:
return None
for match in _APPLICATION_LABEL_RE.finditer(dump):
label = (match.group(1) or match.group(2) or "").strip()
if label and label not in ("null", ""):
return label
return None
@@ -0,0 +1,91 @@
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
Multi-line ActivityRecord wraps are handled by joining the block until the
record closes or a state line arrives (R4 finding 3: match on the joined
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
"""
import re
_RESUMED_MARKERS = (
("mResumedActivity", "mResumedActivity:"),
("topResumedActivity", "topResumedActivity:"),
("ResumedActivity", "ResumedActivity:"),
("Resumed", "Resumed:"),
)
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
# anything up to the first `/` (or closing brace when the activity is bare).
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
def _marker_kind(stripped: str) -> str | None:
for kind, marker in _RESUMED_MARKERS:
if stripped.startswith(marker):
return kind
return None
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
blocks: list[tuple[str, str]] = []
kind: str | None = None
buf = ""
for raw in dump.split("\n"):
stripped = raw.strip()
marker = _marker_kind(stripped)
if marker is not None:
if buf:
blocks.append((kind, buf))
kind, buf = marker, stripped
continue
if kind is None:
continue
# Continuation of a wrapped ActivityRecord: join until the record
# closes (brace) or the state line completes the block (R4 finding 3).
buf += " " + stripped
if "}" in stripped or "state=" in stripped:
blocks.append((kind, buf))
kind, buf = None, ""
if buf:
blocks.append((kind, buf))
return blocks
def _extract_package(text: str) -> str | None:
match = _PACKAGE_RE.search(text)
if match is None:
return None
package = match.group(1) or match.group(2)
if not package or package in ("null", "Window{"):
return None
return package
def parse_foreground(dump: str) -> str | None:
"""Foreground package: mResumedActivity primary, topResumed fallback.
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
callers must not fall back to window-focus parsing (R4 finding 2).
"""
if not dump:
return None
blocks = _resumed_blocks(dump)
for kind, text in blocks:
if kind == "topResumedActivity":
continue
package = _extract_package(text)
if package:
return package
for kind, text in blocks:
if kind != "topResumedActivity":
continue
package = _extract_package(text)
if package:
return package
return None
@@ -13,20 +13,34 @@ class PlaybackIntent:
Attributes: Attributes:
target_state: The MediaPlayerState we expect after the command. target_state: The MediaPlayerState we expect after the command.
command: The service/command name (for retries and logging). command: The command/service name (for retries and logging).
created_at: Monotonic timestamp when the intent was created. created_at: Monotonic timestamp when the intent was created.
prior_state: The source state before the intent (for detecting external overrides). prior_state: The source state before the intent (for detecting external overrides).
retry_count: Number of retries attempted so far. retry_count: Number of retries attempted so far.
via_adb: True when the command was successfully served by the ADB
keyevent path — the retry must then go over ADB too, not back to
the remote that did not serve it.
adb_command: The ADB keycode command name matching the intent
(play/pause/stop/next/previous); set together with via_adb.
via_protocol: True when the command was served by the remote-protocol
key push (Phase 2) — the retry then goes over the protocol key
again instead of the other transports.
protocol_key: The protocol keycode string served with via_protocol.
""" """
target_state: str # MediaPlayerState value (e.g. "playing", "paused") target_state: str # MediaPlayerState value (e.g. "playing", "paused")
command: str command: str
created_at: float created_at: float
prior_state: str | None = None prior_state: str | None = None
retry_count: int = 0 retry_count: int = 0
via_adb: bool = False
adb_command: str | None = None
via_protocol: bool = False
protocol_key: str | None = None
@classmethod @classmethod
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent": def create(
cls, target_state: str, command: str, prior_state: str | None = None
) -> "PlaybackIntent":
return cls( return cls(
target_state=target_state, target_state=target_state,
command=command, command=command,
@@ -45,4 +59,6 @@ class PlaybackIntent:
"command": self.command, "command": self.command,
"retries": self.retry_count, "retries": self.retry_count,
"age_s": self.age_seconds, "age_s": self.age_seconds,
"via_adb": self.via_adb,
"adb_command": self.adb_command,
} }
@@ -0,0 +1,79 @@
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
the ``cmp=package`` part so the connection manager can stamp the foreground
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
source) is untouched: the stream never reads playback state.
"""
import re
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
def start_records(lines: list[str]) -> list[str]:
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
A record opens at a line that matches a log timestamp and mentions
ActivityTaskManager + START; subsequent lines that do NOT look like a
fresh log record are treated as continuations (the intent JSON can wrap
across lines). Anything else closes/open the next record.
"""
records: list[str] = []
current: list[str] = []
for line in lines:
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
if current:
records.append("\n".join(current))
current = [line]
elif current and not _LOG_RECORD_RE.match(line):
current.append(line) # wrapped intent text
else:
if current:
records.append("\n".join(current))
current = []
if current:
records.append("\n".join(current))
return records
def foreground_from_logcat(lines: list[str]) -> str | None:
"""The foreground package from the LAST valid START record, or None.
Non-START lines are ignored entirely; records whose intent carries no
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
tolerated — the user prefix is never part of the cmp value.
"""
package: str | None = None
for record in start_records(lines):
match = _CMP_RE.search(record)
if not match:
continue
candidate = match.group(1)
if candidate and candidate != "null":
package = candidate # last valid START wins
return package
def foreground_packages(lines: list[str]) -> list[str]:
"""Every distinct valid START package in order (test seam)."""
seen: list[str] = []
for record in start_records(lines):
match = _CMP_RE.search(record)
if not match:
continue
candidate = match.group(1)
if candidate and candidate != "null" and candidate not in seen:
seen.append(candidate)
return seen
__all__ = [
"foreground_from_logcat",
"foreground_packages",
"start_records",
]
@@ -4,12 +4,20 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
information from any app using Android's MediaSession API. information from any app using Android's MediaSession API.
""" """
import re
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class MediaSessionInfo: class MediaSessionInfo:
"""Parsed media session data from Android.""" """Parsed media session data from Android.
Playback position fields come from the session's PlaybackState line:
``position`` is the playback offset in ms as of ``updated`` — which is a
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
wallclock value. Use :meth:`position_at_uptime` with the device's current
uptime to extrapolate the live position.
"""
title: str | None = None title: str | None = None
artist: str | None = None artist: str | None = None
@@ -17,6 +25,56 @@ class MediaSessionInfo:
package: str | None = None package: str | None = None
playing: bool = False playing: bool = False
paused: bool = False paused: bool = False
position: float | None = None
speed: float | None = None
updated: int | None = None
def position_at_uptime(self, now_uptime_ms: float) -> float | None:
"""Extrapolate the live playback position at device uptime ``now_uptime_ms``.
Mirrors HA core's convention: ``position + (now - updated) * speed``.
Returns None when the session carries no position sample.
"""
if self.position is None or self.updated is None:
return None
speed = self.speed if self.speed is not None else 1.0
return self.position + (now_uptime_ms - self.updated) * speed
_PLAYBACK_STATE_RE = re.compile(r"PlaybackState\s*\{([^}]*)\}")
def _parse_playback_state(content: str) -> dict[str, str]:
"""Parse the ``key=value`` pairs inside a PlaybackState block.
Splitting on ", " (rather than searching for ``position=`` anywhere in
the line) keeps the ``buffered position`` field from being mistaken for
the real ``position``.
"""
fields: dict[str, str] = {}
for part in content.split(", "):
key, _, value = part.partition("=")
if value:
fields[key.strip()] = value.strip()
return fields
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
try:
return float(value)
except ValueError:
return None
def _parse_int(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]: def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
@@ -32,6 +90,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_pkg: str | None = None current_pkg: str | None = None
in_active = False in_active = False
current_state = 0 # 0=unknown, 2=paused, 3=playing current_state = 0 # 0=unknown, 2=paused, 3=playing
current_position: float | None = None
current_speed: float | None = None
current_updated: int | None = None
current_meta: MediaSessionInfo | None = None current_meta: MediaSessionInfo | None = None
for line in dump.split("\n"): for line in dump.split("\n"):
@@ -47,16 +108,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_pkg = pkg[1].strip() current_pkg = pkg[1].strip()
in_active = False in_active = False
current_state = 0 current_state = 0
current_position = None
current_speed = None
current_updated = None
current_meta = None current_meta = None
if "active=true" in stripped: if "active=true" in stripped:
in_active = True in_active = True
if in_active and "state=PlaybackState" in stripped: if in_active and "state=PlaybackState" in stripped:
if "state=3" in stripped: match = _PLAYBACK_STATE_RE.search(stripped)
current_state = 3 if match:
elif "state=2" in stripped: fields = _parse_playback_state(match.group(1))
current_state = 2 try:
current_state = int(fields.get("state", 0))
except ValueError:
current_state = 0
current_position = _parse_float(fields.get("position"))
current_speed = _parse_float(fields.get("speed"))
current_updated = _parse_int(fields.get("updated"))
if in_active and "metadata: size=" in stripped and "description=" in stripped: if in_active and "metadata: size=" in stripped and "description=" in stripped:
desc_start = stripped.find("description=") desc_start = stripped.find("description=")
@@ -69,6 +139,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info.package = current_pkg info.package = current_pkg
info.playing = current_state == 3 info.playing = current_state == 3
info.paused = current_state == 2 info.paused = current_state == 2
info.position = current_position
info.speed = current_speed
info.updated = current_updated
if len(parts) >= 1 and parts[0] not in ("null", ""): if len(parts) >= 1 and parts[0] not in ("null", ""):
info.title = parts[0] info.title = parts[0]
@@ -97,6 +170,34 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
return sessions return sessions
def parse_audio_playback(dump: str) -> list[str]:
"""Parse the "Audio playback (lastly played comes first)" section.
Returns the package names that recently played audio, in the dump's own
order (most recently played first). This is a zero-cost hint from the same
``dumpsys media_session`` call: an app shows up here even when it publishes
no MediaSession (e.g. TiviMate, R2). Callers may filter system processes
such as ``com.google.android.apps.mediashell`` (the Chromecast receiver).
"""
if not dump:
return []
packages: list[str] = []
in_audio = False
for line in dump.split("\n"):
stripped = line.strip()
if not in_audio:
in_audio = "Audio playback" in stripped
continue
marker = "packages="
if marker not in stripped:
continue
pkg_part = stripped.split(marker, 1)[1]
for pkg in pkg_part.replace(",", " ").split():
if pkg and pkg not in packages:
packages.append(pkg)
return packages
def parse_media_session( def parse_media_session(
dump: str, foreground_package: str | None = None dump: str, foreground_package: str | None = None
) -> MediaSessionInfo | None: ) -> MediaSessionInfo | None:
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
recent_tasks: list[dict], recent_tasks: list[dict],
media_sessions: list[MediaSessionInfo] | None = None, media_sessions: list[MediaSessionInfo] | None = None,
foreground_package: str | None = None, foreground_package: str | None = None,
last_known: dict[str, MediaSessionInfo] | None = None,
) -> list[dict] | None: ) -> list[dict] | None:
"""Build a list of running app dicts from recent tasks + media sessions. """Build a list of running app dicts from recent tasks + media sessions.
@@ -19,6 +20,9 @@ def running_apps_from_tasks(
recent_tasks: List of {"package": str} from ADB recent tasks. recent_tasks: List of {"package": str} from ADB recent tasks.
media_sessions: Optional media sessions for playback state enrichment. media_sessions: Optional media sessions for playback state enrichment.
foreground_package: The current foreground app package. foreground_package: The current foreground app package.
last_known: Optional per-package TTL-filtered last-known-state cache
(P3-3b) — apps without a live session carry their last known
playback state as ``last_known_state``/``last_known_title``.
Returns None if there are fewer than 2 apps (no point showing a switcher Returns None if there are fewer than 2 apps (no point showing a switcher
with only one option). with only one option).
@@ -57,6 +61,15 @@ def running_apps_from_tasks(
app["artist"] = session.artist app["artist"] = session.artist
else: else:
app["state"] = "running" app["state"] = "running"
# P3-3b: no live session — surface the TTL-capped last-known state
cached = (last_known or {}).get(pkg)
if cached is not None:
if cached.playing:
app["last_known_state"] = "playing"
elif cached.paused:
app["last_known_state"] = "paused"
if cached.title:
app["last_known_title"] = cached.title
apps.append(app) apps.append(app)
@@ -0,0 +1,73 @@
"""Shared config helpers for the NVIDIA Shield integration."""
import os
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
DEFAULT_ADB_PORT,
DOMAIN,
)
@dataclass
class AdbConfig:
"""Resolved ADB connection settings for a config entry."""
host: str
port: int
key_path: str
def adb_key_path(hass: HomeAssistant) -> str:
"""Path of the ADB private key for this integration (may not exist yet)."""
return os.path.join(hass.config.config_dir, ".storage", DOMAIN, "adbkey")
def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
"""Local (certfile, keyfile) paths for the remote protocol client.
Mirrors core androidtv_remote's storage pattern: private material under
``.storage/<domain>/``. Shared between config-flow pairing and runtime —
an entry aborts duplicate setup by unique_id (= device MAC), so one pair
of files suffices per install.
"""
base = os.path.join(hass.config.config_dir, ".storage", DOMAIN)
os.makedirs(base, exist_ok=True)
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
def discover_adb_host(
hass: HomeAssistant, androidtv_entity_id: str | None
) -> str | None:
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
if not androidtv_entity_id:
return None
registry = er.async_get(hass)
entity_entry = registry.async_get(androidtv_entity_id)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
"""Resolve host/port/key_path for the entry's ADB connection, or None.
Priority: explicit ADB host/port options > host from the AndroidTV
entity's own config entry.
"""
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host:
return None
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
return AdbConfig(host=host, port=port, key_path=adb_key_path(hass))
@@ -7,10 +7,14 @@
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield", "documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
"iot_class": "local_polling", "iot_class": "local_push",
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues", "issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
"requirements": [ "requirements": [
"adb-shell>=0.4.0" "adb-shell>=0.4.0",
"androidtvremote2>=0.2.0"
], ],
"version": "2026.10.08" "version": "2026.10.08",
"zeroconf": [
"_androidtvremote2._tcp.local."
]
} }
+570 -177
View File
@@ -1,23 +1,30 @@
"""Media player entity for Nvidia Shield. """Media player entity for Nvidia Shield.
Phase 2 architecture (docs/MVP_PLAN.md §2): the entry's ShieldConnectionManager
(connection.py) owns both transports — ADB (adb_shell, state source) and the
absorbed remote protocol (androidtvremote2, local push) — plus their StateStore;
this entity is a thin callback-view over it.
State architecture: State architecture:
- Power/availability: androidtv_remote (local push, fast) - Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
- Playback state: Jellyfin (polling, slow 10-30s) - Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
- Metadata: Jellyfin attributes - Metadata: Jellyfin attributes
- ALL commands: androidtv_remote (Jellyfin is read-only) - Commands: protocol key push → ADB keyevent → external androidtv_remote
(the last tier is transitional; removed in Phase 4)
Intent tracking bridges the gap between instant commands and slow Jellyfin polling: Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately 1. User presses pause → intent recorded → UI shows paused immediately
2. Command sent to androidtv_remote → Shield pauses on screen 2. Command sent over the serving transport → Shield pauses on screen
3. 10-30s later Jellyfin confirms paused → intent cleared 3. 10-30s later Jellyfin confirms paused → intent cleared
4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth 4. If Jellyfin doesn't confirm in 30s → retry once on the serving
transport (protocol/ADB stamped on the intent) → surrender to truth
""" """
import asyncio
import logging import logging
import time import time
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks import voluptuous as vol
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerDeviceClass, MediaPlayerDeviceClass,
MediaPlayerEntity, MediaPlayerEntity,
@@ -25,34 +32,40 @@ from homeassistant.components.media_player import (
MediaPlayerState, MediaPlayerState,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event from homeassistant.helpers.event import async_call_later, async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import ( from .const import (
ADB_POLL_INTERVAL,
ADB_RETRY_INTERVAL,
ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING, ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY, ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT, ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS, ATTR_MEDIA_DAG_RUNNING_APPS,
CONF_ADB_ENABLED, CHANNEL_DIGIT_DELAY,
CONF_ADB_HOST, CONF_ADB_COMMAND_ENABLED,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT, DEFAULT_APP_DIRECTORY,
DOMAIN, DOMAIN,
PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS,
SERVICE_ADB_COMMAND,
TIVIMATE_PACKAGE,
) )
from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .jellyfin import fetch_session_media_info from .jellyfin import fetch_session_media_info
from .power import discover_power_siblings from .power import discover_power_siblings
@@ -73,6 +86,8 @@ _BASE_FEATURES = (
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
) )
_PLAYBACK_FEATURES = ( _PLAYBACK_FEATURES = (
@@ -90,6 +105,49 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)]) 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
# t26 F4: another entry may already own the target MAC — the collision
# pre-check falls back to the config-entry id instead of re-binding the
# unique_id to a MAC that could collide with a real second instance.
other_entries = getattr(
getattr(hass, "config_entries", None), "entries", []
) or []
if any(
getattr(other, "unique_id", None) == new_id
for other in other_entries
if getattr(other, "entry_id", None) != entry.entry_id
):
_LOGGER.warning(
"P5-1: unique_id migration target %r already in use — falling "
"back to the config-entry id",
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): class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@@ -98,11 +156,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_name = None
_attr_should_poll = False _attr_should_poll = False
# Overridden as a property below: ADB-health aware
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
self._entry = entry self._entry = entry
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY] self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player" self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
self._unsub_listeners: list = [] self._unsub_listeners: list = []
@@ -116,11 +175,32 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# Jellyfin media stream info (resolution, codec, etc.) # Jellyfin media stream info (resolution, codec, etc.)
self._media_info: dict | None = None self._media_info: dict | None = None
# ADB media session, wake state, and running apps # ADB-derived state comes from the entry's ShieldConnectionManager
self._adb_poller: AdbMediaSessionPoller | None = None # (connection.py) — this entity is a thin callback-view over its store.
self._adb_poll_unsub: CALLBACK_TYPE | None = None self._manager: ShieldConnectionManager | None = None
self._adb_awake: bool | None = None self._manager_unsub: CALLBACK_TYPE | None = None
self._running_apps: list[dict] = [] # In-flight channel digit entry (play_media(channel) — §7); a new
# play_media cancels the previous sequence (core task-cancel guard).
self._channel_task = None
# Last channel entered via play_media(channel) — the foreground
# tier's title fallback when an app publishes no session (P3-3a).
self._last_channel: str | None = None
# Entity-owned background tasks (intent retries, media-info fetch);
# cancelled on unload. The manager owns the socket — never touch it
# here directly.
self._entity_tasks: set = set()
@property
def available(self) -> bool:
"""The bridge entities stay functional without ADB (passive manager).
Unavailable only while an active ADB manager is down — its data is
unreliable until the watchdog reconnects (t5-F2).
"""
manager = getattr(self._entry, "runtime_data", None)
if not isinstance(manager, ShieldConnectionManager):
return True
return manager.connected or manager.awake is not None or not manager.available
@property @property
def _jellyfin_entity(self) -> str | None: def _jellyfin_entity(self) -> str | None:
@@ -147,22 +227,35 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _power_entity(self) -> str | None: def _power_entity(self) -> str | None:
return self._entry.options.get(CONF_POWER_ENTITY) or None return self._entry.options.get(CONF_POWER_ENTITY) or None
@property
def _adb_enabled(self) -> bool:
return self._entry.options.get(CONF_ADB_ENABLED, False)
@property @property
def _adb_store_info(self) -> bool: def _adb_store_info(self) -> bool:
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True) return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
# P4-2a: opt-in as an adb_command target (option default off).
# Registered at setup; unregistered at unload when the last
# participating entity goes away.
if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False):
# F4: the registry key is computed once and stored on the
# entity, so add/remove always agree even when entity_id is
# assigned by HA core after the first read.
self._adb_command_key = (
getattr(self, "entity_id", None) or self._attr_unique_id
)
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
_adb_command_service_ensure(self.hass)
# Discover power sensor siblings from the power switch # Discover power sensor siblings from the power switch
if self._power_entity: if self._power_entity:
self._power_sensors = discover_power_siblings(self.hass, self._power_entity) self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
# Set up ADB media session poller # Consume the entry's ShieldConnectionManager (thin-view pattern)
if self._adb_enabled: manager = getattr(self._entry, "runtime_data", None)
await self._setup_adb() if manager is not None:
self._manager = manager
self._manager_unsub = manager.register_listener(
self._on_manager_update
)
entities = [self._androidtv_entity] entities = [self._androidtv_entity]
if self._jellyfin_entity: if self._jellyfin_entity:
@@ -180,11 +273,21 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
unsub() unsub()
self._unsub_listeners.clear() self._unsub_listeners.clear()
self._clear_intent() self._clear_intent()
if self._adb_poll_unsub: if self._manager_unsub:
self._adb_poll_unsub() self._manager_unsub()
self._adb_poll_unsub = None self._manager_unsub = None
if self._adb_poller: # Cancel in-flight entity tasks so they cannot outlive the entity.
await self._adb_poller.disconnect() for task in self._entity_tasks:
task.cancel()
self._entity_tasks.clear()
# P4-2a: drop this entity's service participation (same key as add)
if (
(key := getattr(self, "_adb_command_key", None)) is not None
and _ADB_COMMAND_TARGETS.pop(key, None) is not None
and self.hass
):
_adb_command_service_maybe_remove(self.hass)
# ── Intent management ── # ── Intent management ──
@@ -243,7 +346,27 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)", "Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
intent.command, jf_state, intent.target_state, intent.retry_count, intent.command, jf_state, intent.target_state, intent.retry_count,
) )
self.hass.async_create_task( if (
intent.via_protocol
and intent.protocol_key
and self._manager is not None
):
# The command was served by the protocol key push — retry there.
self._track_task(
self._manager.send_protocol_key(intent.protocol_key)
)
elif (
intent.via_adb
and intent.adb_command
and self._manager is not None
and self._manager.connected
):
# The command was originally served by ADB — retry over ADB.
self._track_task(
self._manager.send_media_command(intent.adb_command)
)
else:
self._track_task(
self._cmd(intent.command) self._cmd(intent.command)
) )
self._intent_timer_unsub = async_call_later( self._intent_timer_unsub = async_call_later(
@@ -268,9 +391,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
new_state_obj = event.data.get("new_state") new_state_obj = event.data.get("new_state")
# Power off clears any intent — device is off, intent is moot # Power off clears any intent — device is off, intent is moot
if entity_id in (self._androidtv_entity, self._power_entity) and new_state_obj: if (
if not self._is_power_on(): entity_id in (self._androidtv_entity, self._power_entity)
if self._intent: and new_state_obj
and not self._is_power_on()
and self._intent
):
_LOGGER.debug("Intent cleared: device powered off") _LOGGER.debug("Intent cleared: device powered off")
self._clear_intent() self._clear_intent()
@@ -296,7 +422,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# Fetch media stream info when playing # Fetch media stream info when playing
if new_jf in (STATE_PLAYING, STATE_PAUSED): if new_jf in (STATE_PLAYING, STATE_PAUSED):
entity_picture = new_state_obj.attributes.get("entity_picture", "") entity_picture = new_state_obj.attributes.get("entity_picture", "")
self.hass.async_create_task( self._track_task(
self._fetch_media_info(entity_picture) self._fetch_media_info(entity_picture)
) )
else: else:
@@ -313,108 +439,23 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None return None
return self.hass.states.get(entity_id) return self.hass.states.get(entity_id)
def _discover_adb_host(self) -> str | None:
"""Auto-detect ADB host from the androidtv_remote config entry for our entity."""
from homeassistant.helpers import entity_registry as er
registry = er.async_get(self.hass)
entity_entry = registry.async_get(self._androidtv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def _setup_adb(self) -> None:
"""Initialize ADB connection and start polling."""
try:
# Only auto-connect if ADB was previously paired (key exists)
import os
key_dir = os.path.join(self.hass.config.config_dir, ".storage", DOMAIN)
key_file = os.path.join(key_dir, "adbkey")
if not await self.hass.async_add_executor_job(os.path.exists, key_file):
_LOGGER.info(
"ADB key not found — skipping auto-connect. "
"Use the Pair ADB button or reconfigure the integration."
)
return
# Get device IP: explicit config > auto-detect from androidtv_remote
host = self._entry.options.get(CONF_ADB_HOST)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host:
host = self._discover_adb_host()
if not host:
_LOGGER.warning("Could not determine ADB host. Set it in integration options.")
return
key_path = key_file
self._adb_poller = AdbMediaSessionPoller(host, port, key_path)
if await self._adb_poller.connect():
# Start periodic polling
self._adb_poll_unsub = async_call_later(
self.hass, ADB_POLL_INTERVAL, self._adb_poll_callback
)
_LOGGER.info("ADB media session polling started for %s", host)
else:
_LOGGER.warning(
"ADB connection to %s failed. If this is the first connection, "
"approve the ADB debugging prompt on the device. "
"Retrying in %d seconds.",
host, ADB_RETRY_INTERVAL,
)
self._adb_poll_unsub = async_call_later(
self.hass, ADB_RETRY_INTERVAL, self._adb_poll_callback
)
except Exception:
_LOGGER.exception("Failed to set up ADB")
@callback @callback
def _adb_poll_callback(self, _now) -> None: def _on_manager_update(self) -> None:
"""Schedule ADB poll as a task.""" """Connection manager's store changed — re-evaluate our state."""
self.hass.async_create_task(self._adb_poll())
async def _adb_poll(self) -> None:
"""Poll ADB for wake state and media session, then schedule next poll."""
try:
if not self._adb_poller:
return
# Always check wake state
prev_awake = self._adb_awake
self._adb_awake = await self._adb_poller.is_awake()
# Only poll media session when awake and Jellyfin is NOT active
jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
if self._adb_awake:
# Always poll ADB when awake — pass the foreground app
# so the parser prefers its session over stale ones
await self._adb_poller.poll(foreground_package=self.app_id)
self._running_apps = await self._adb_poller.poll_running_apps()
elif self._adb_poller.last_info:
# Clear media info when asleep
self._adb_poller.clear_info()
self._running_apps = []
# Always update state (wake state may have changed)
if self._adb_awake != prev_awake or self._adb_poller.last_info:
self.async_write_ha_state() self.async_write_ha_state()
except Exception:
_LOGGER.debug("ADB poll error", exc_info=True) def _track_task(self, coro) -> None:
finally: """Dispatch an entity background task; cancelled on unload.
# Use slow retry if ADB is disconnected, normal interval if connected
if self._adb_enabled: Entity-owned tasks (intent retries, media-info fetch) must never go
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL through a bare hass.async_create_task: they would survive removal
self._adb_poll_unsub = async_call_later( (reviewer finding F2). The connection manager owns its own tasks and
self.hass, interval, self._adb_poll_callback the socket — nothing here touches it directly.
) """
task = self.hass.async_create_task(coro)
self._entity_tasks.add(task)
task.add_done_callback(self._entity_tasks.discard)
return task
async def _fetch_media_info(self, entity_picture: str) -> None: async def _fetch_media_info(self, entity_picture: str) -> None:
"""Read stream info from the HA Jellyfin integration's coordinator.""" """Read stream info from the HA Jellyfin integration's coordinator."""
@@ -431,11 +472,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED): if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
return True return True
# If we have an active intent for play/pause, media is conceptually active # If we have an active intent for play/pause, media is conceptually active
if self._intent and self._intent.target_state in ( return bool(
STATE_PLAYING, STATE_PAUSED self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED)
): )
return True
return False
# ── State ── # ── State ──
@@ -446,11 +485,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
ADB is the authority — it knows if the device is awake or asleep. ADB is the authority — it knows if the device is awake or asleep.
The plug being "on" just means the outlet has power (standby). The plug being "on" just means the outlet has power (standby).
""" """
# 1. ADB wake state — most accurate # 1. Protocol is_on push — fastest truth when the transport is live
if self._adb_awake is not None: protocol_is_on = self._manager.is_on if self._manager else None
return self._adb_awake if protocol_is_on is not None:
return protocol_is_on
# 2. Power entity — only trust "off" (plug pulled = definitely off) # 2. ADB wake state — exact mWakefulness parse
adb_awake = self._manager.awake if self._manager else None
if adb_awake is not None:
return adb_awake
# 3. Power entity — only trust "off" (plug pulled = definitely off)
if self._power_entity: if self._power_entity:
power_state = self._read(self._power_entity) power_state = self._read(self._power_entity)
if power_state and power_state.state == "off": if power_state and power_state.state == "off":
@@ -469,7 +514,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
power = self._is_power_on() power = self._is_power_on()
if power is False: if power is False:
return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot) if self._manager is not None and self._manager.is_on is False:
return MediaPlayerState.OFF # protocol says powered off
# ADB says asleep — standby (yellow dot)
return MediaPlayerState.IDLE
if power is None: if power is None:
return MediaPlayerState.IDLE return MediaPlayerState.IDLE
@@ -487,13 +535,20 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
# Tier 3: ADB media session (non-Jellyfin apps) # Tier 3: ADB media session (non-Jellyfin apps)
if self._adb_poller and self._adb_poller.last_info: adb_info = self._manager.best_session(self.app_id) if self._manager else None
adb_info = self._adb_poller.last_info if adb_info:
if adb_info.playing: if adb_info.playing:
return MediaPlayerState.PLAYING return MediaPlayerState.PLAYING
if adb_info.paused: if adb_info.paused:
return MediaPlayerState.PAUSED return MediaPlayerState.PAUSED
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
# current_app push, else the ADB mResumedActivity parse) but publishes
# no media session. The device is still ON; the now-playing tier
# carries the last-known/channel/audio-hint identity.
if self._manager is not None and self._manager.foreground_app:
return MediaPlayerState.ON
# Tier 4: On but no media # Tier 4: On but no media
return MediaPlayerState.ON return MediaPlayerState.ON
@@ -502,9 +557,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if self._jf_active(): if self._jf_active():
return _BASE_FEATURES | _PLAYBACK_FEATURES return _BASE_FEATURES | _PLAYBACK_FEATURES
# ADB media session also supports playback controls # ADB media session also supports playback controls
if self._adb_poller and self._adb_poller.last_info: adb_info = self._manager.best_session(self.app_id) if self._manager else None
adb_info = self._adb_poller.last_info if adb_info and (adb_info.playing or adb_info.paused):
if adb_info.playing or adb_info.paused:
return _BASE_FEATURES | _PLAYBACK_FEATURES return _BASE_FEATURES | _PLAYBACK_FEATURES
return _BASE_FEATURES return _BASE_FEATURES
@@ -516,13 +570,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def app_id(self): def app_id(self):
atv = self._read(self._androidtv_entity) """Android TV entity first; standalone setups fall back to the
return atv.attributes.get("app_id") if atv else None protocol current-app push (raw package — both stamp the store)."""
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
if atv is not None:
app_id = atv.attributes.get("app_id")
if app_id:
return app_id
if self._manager is not None:
return self._manager.current_app
return None
@property
def _app_name_map(self) -> dict:
"""The user's apps name/icon map (P4-1 options editor)."""
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
@property @property
def app_name(self): def app_name(self):
atv = self._read(self._androidtv_entity) """P5-1 resolution order — explicit override first:
return atv.attributes.get("app_name") if atv else None
1. CONF_APP_NAME_MAP (the user's explicit override)
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
demand — a miss enqueues the fetch and falls through)
3. androidtv entity tier (when configured)
4. raw package (protocol push / ADB foreground parse)
"""
package = self._manager.foreground_app if self._manager else None
if package:
mapped = self._app_name_map.get(package)
if mapped:
return mapped
label = self._manager.app_label(package)
if label:
return label
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
if atv is not None:
app_name = atv.attributes.get("app_name")
if app_name:
return app_name
if self._manager is not None and package:
return package
return None
@property @property
def media_content_type(self): def media_content_type(self):
@@ -558,7 +647,40 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def media_image_url(self): def media_image_url(self):
return self._jf_attr("entity_picture") if self._jf_active() else None """Jellyfin session picture, else the P5-2 apps-map icon override."""
if self._jf_active():
jf = self._jf_attr("entity_picture")
if jf:
return jf
package = self._manager.foreground_app if self._manager else None
if package:
icon_map = self._entry.options.get(CONF_APP_ICON_MAP, {})
return icon_map.get(package)
return None
@property
def volume_level(self) -> float | None:
"""Protocol volume push readout (C-VOL — level only, no VOLUME_SET)."""
if self._manager is None:
return None
info = self._manager.volume_info
if info is None:
return None
level, volume_max, _muted = info
try:
if volume_max and volume_max > 0:
return level / volume_max
except (TypeError, ValueError):
pass
return None
@property
def is_volume_muted(self) -> bool | None:
"""Real mute state from the protocol push (was unknowable pre-Phase 2)."""
if self._manager is None:
return None
info = self._manager.volume_info
return info[2] if info is not None else None
def _get_now_playing(self) -> dict | None: def _get_now_playing(self) -> dict | None:
if not self._jf_active(): if not self._jf_active():
@@ -584,11 +706,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _get_adb_now_playing(self) -> dict | None: def _get_adb_now_playing(self) -> dict | None:
"""Build now-playing from ADB media session (non-Jellyfin apps).""" """Build now-playing from ADB media session (non-Jellyfin apps)."""
if not self._adb_poller or not self._adb_poller.last_info: info = self._manager.best_session(self.app_id) if self._manager else None
if not info:
return None return None
if not self._adb_store_info: if not self._adb_store_info:
# Privacy mode: show that something is playing but not what # Privacy mode: show that something is playing but not what
info = self._adb_poller.last_info
if info.playing or info.paused: if info.playing or info.paused:
return { return {
"state": "playing" if info.playing else "paused", "state": "playing" if info.playing else "paused",
@@ -597,7 +719,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
} }
return None return None
info = self._adb_poller.last_info
if not info.title: if not info.title:
return None return None
np = { np = {
@@ -611,6 +732,42 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
np["album"] = info.album np["album"] = info.album
return np return np
def _get_app_now_playing(self) -> dict | None:
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
no media session (TiviMate, R2). Title = last known state, else the
last channel entered, else the zero-cost audio-playback hint.
"""
manager = self._manager
if manager is None:
return None
foreground = manager.foreground_app
if not foreground:
return None
state = "idle"
title = None
info = manager.last_known_session(foreground)
if info is not None and (info.playing or info.paused):
state = "playing" if info.playing else "paused"
# Privacy mode: identity stays the app, never the media title
title = None if not self._adb_store_info else info.title
if title is None and not self._adb_store_info:
# F3: privacy mode first — identity stays the app, so neither the
# channel number nor the media title can leak as the identity.
title = self.app_name or self.app_id or foreground
if title is None and self._last_channel and (
# F1: the channel number belongs to the channel app only — never
# leak it to the launcher or another foreground app.
foreground == TIVIMATE_PACKAGE or foreground in manager.audio_playback
):
title = self._last_channel
if title is None and foreground in manager.audio_playback:
# E hint (zero round-trip cost): audio was recently played
title = "Media recently active"
if title is None:
return None # bare foreground app — the state stays ON regardless
return {"state": state, "title": title, "source": "app"}
def _get_power_data(self) -> dict | None: def _get_power_data(self) -> dict | None:
"""Read current values from discovered power sensors.""" """Read current values from discovered power sensors."""
if not self._power_sensors: if not self._power_sensors:
@@ -629,22 +786,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _get_running_apps(self) -> list[dict] | None: def _get_running_apps(self) -> list[dict] | None:
"""Build a list of running apps from ADB recent tasks + media sessions.""" """Build a list of running apps from ADB recent tasks + media sessions."""
if not self._adb_poller or not self._running_apps: if not self._manager:
return None
apps = self._manager.running_apps
if not apps:
return None return None
if not self._adb_store_info: if not self._adb_store_info:
return None return None
return running_apps_from_tasks( return running_apps_from_tasks(
self._running_apps, apps,
self._adb_poller.all_sessions, self._manager.active_sessions,
foreground_package=self.app_id, foreground_package=self.app_id,
last_known=self._manager.last_known_map(),
) )
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
# Now playing: prefer Jellyfin, fall back to ADB media session # Now playing: prefer Jellyfin, fall back to ADB media session,
# then the foreground tier (P3-3a).
now_playing = self._get_now_playing() now_playing = self._get_now_playing()
if not now_playing: if not now_playing:
now_playing = self._get_adb_now_playing() now_playing = self._get_adb_now_playing()
if not now_playing:
now_playing = self._get_app_now_playing()
attrs = {"app_id": self.app_id, "app_name": self.app_name, attrs = {"app_id": self.app_id, "app_name": self.app_name,
ATTR_MEDIA_DAG_ROLE: "source", ATTR_MEDIA_DAG_ROLE: "source",
@@ -686,16 +850,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
await self._cmd("turn_off") await self._cmd("turn_off")
async def async_volume_up(self): async def async_volume_up(self):
await self._cmd("volume_up") await self._remote_key_cmd("volume_up", "volume_up")
async def async_volume_down(self): async def async_volume_down(self):
await self._cmd("volume_down") await self._remote_key_cmd("volume_down", "volume_down")
async def async_mute_volume(self, mute): async def async_mute_volume(self, mute):
await self._cmd("volume_mute", is_volume_muted=mute) """Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL)."""
await self._remote_key_cmd("volume_mute", "mute")
async def _media_cmd(self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None) -> None: async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Send a media command. Uses ADB dispatch for non-Jellyfin apps when available.""" """Simple key command without intent bookkeeping: protocol → ADB → legacy."""
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
if (
protocol_key
and self._manager is not None
and await self._manager.send_protocol_key(protocol_key)
):
return
if self._manager is not None and await self._manager.send_media_command(adb_key):
return
# Transitional: external androidtv_remote entity (removed in Phase 4)
await self._cmd(remote_key)
async def _media_cmd(
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
) -> None:
"""Send a media command over the fastest available command path.
Order (Phase 2): protocol key push → ADB keyevent → the external
androidtv_remote entity (transitional until Phase 4 removes it).
"""
if intent_state: if intent_state:
self._set_intent(ha_service, intent_state) self._set_intent(ha_service, intent_state)
self.async_write_ha_state() self.async_write_ha_state()
@@ -704,12 +889,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
jf = self._read(self._jellyfin_entity) jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
if self._adb_poller and self._adb_poller.connected and not jf_active: # 1. Protocol key push (fast; works whenever the transport is up)
success = await self._adb_poller.send_media_command(adb_command) protocol_key = PROTOCOL_MEDIA_KEYS.get(ha_service)
if success: if (
protocol_key
and self._manager is not None
and await self._manager.send_protocol_key(protocol_key)
):
if self._intent is not None:
self._intent.via_protocol = True
self._intent.protocol_key = protocol_key
return return
# Fall back to Android TV Remote # 2. ADB keyevent (socket-independent; exact same commands as before)
if self._manager and self._manager.connected and not jf_active:
success = await self._manager.send_media_command(adb_command)
if success:
# The ADB path served this command — the self-heal retry must
# also go over ADB, not back to the remote.
if self._intent is not None:
self._intent.via_adb = True
self._intent.adb_command = adb_command
return
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
await self._cmd(ha_service) await self._cmd(ha_service)
async def async_media_play(self): async def async_media_play(self):
@@ -729,7 +932,197 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
async def async_bring_to_foreground(self, package: str) -> bool: async def async_bring_to_foreground(self, package: str) -> bool:
"""Bring a specific app to the foreground via ADB.""" """Bring a specific app to the foreground via ADB."""
if not self._adb_poller: if self._manager is None or not self._manager.connected:
_LOGGER.warning("ADB not available for bring_to_foreground") _LOGGER.warning("ADB not available for bring_to_foreground")
return False return False
return await self._adb_poller.bring_to_foreground(package) return await self._manager.bring_to_foreground(package)
# ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Root lists the apps map (or the curated default) → play_media(app)."""
from homeassistant.components.media_player import (
BrowseMediaSource,
MediaClass,
)
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
children = [
BrowseMediaSource(
title=name,
media_class=MediaClass.APP,
media_content_type="app",
media_content_id=pkg,
can_play=True,
can_expand=False,
)
for pkg, name in app_map.items()
]
return BrowseMediaSource(
title="Apps",
media_class=MediaClass.DIRECTORY,
media_content_type="apps",
media_content_id="apps",
can_play=False,
can_expand=True,
children=children,
)
# ── play_media (§7): channel digits, app/url launch ──
async def async_play_media(self, media_type: str, media_id: str) -> None:
"""Channel selection by digit number; app/url launches (C-LAUNCH).
Channel path replicates HA core androidtv_remote exactly: digits only,
configurable inter-digit delay (default 0.1 s), no ENTER — and the
padded prior in-flight channel sequence is cancelled first.
TEXT entry (P5-2): play_media("text", "...") — protocol IME send_text
first; the ADB `input text` fallback tier runs only when the
protocol send is unavailable or failed (the Shield may swallow IME
text while its on-screen keyboard is up, RESEARCH_IDEAS §2).
"""
if media_type == "text":
if not media_id:
_LOGGER.warning("play_media(text): empty text ignored")
return
if self._manager is not None and await self._manager.send_text_ime(media_id):
return
if self._manager is not None and await self._manager.send_adb_input_text(media_id):
return
_LOGGER.warning(
"play_media(text): no transport available (protocol down, ADB"
" unavailable) — text not sent"
)
return
if media_type == "channel":
digits = [c for c in media_id if c.isdigit()]
if not digits:
_LOGGER.warning("play_media(channel): no digits in %r", media_id)
return
self._last_channel = "".join(digits)
self._cancel_inflight_channel()
self._channel_task = self._track_task(
self._channel_entry_sequence(digits)
)
return
if media_type in ("app", "url"):
if self._manager is not None and await self._manager.launch_app(media_id):
return
if (
media_type == "app"
and self._manager is not None
and await self._manager.bring_to_foreground(media_id)
):
# Fallback: monkey launch of the bare package (no deep links).
return
_LOGGER.warning(
"play_media(%s): could not launch %s — no available transport",
media_type,
media_id,
)
return
_LOGGER.warning(
"play_media: unsupported media type %r (supported: channel/app/url)"
" — nothing sent",
media_type,
)
def _cancel_inflight_channel(self) -> None:
task = self._channel_task
if task is not None and not task.done():
task.cancel()
self._channel_task = None
async def _channel_entry_sequence(self, digits: list[str]) -> None:
delay = self._entry.options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
try:
for digit in digits:
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
served = False
if self._manager is not None:
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
if not served:
served = await self._manager.send_digit_key(digit)
if not served:
await self._cmd(
"play_media",
media={"media_content_id": digit, "media_content_type": "channel"},
)
await asyncio.sleep(delay)
if (
send_enter
and self._manager is not None
and not await self._manager.send_protocol_key("KEYCODE_ENTER")
):
# ENTER fallback chain: protocol key → ADB keyevent 66.
# (No legacy-entity tier: the external androidtv_remote has
# no ENTER service to delegate to.)
await self._manager.send_enter_key()
except asyncio.CancelledError:
raise
# ── P4-2a: the option-gated adb_command service ──
# Entities register their manager as a target when their entry enables the
# option; the domain service unregisters once the last participant unloads
# (core androidtv service posture: documented, opt-in, default off).
_ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
_ADB_COMMAND_SCHEMA = vol.Schema(
{
vol.Required("command"): str,
vol.Optional("entity_id"): str,
}
)
async def _async_adb_command_service(call) -> None:
"""Run a raw ADB shell command on the targeted entry's connection."""
command = str(call.data.get("command") or "").strip()
if not command:
_LOGGER.warning("adb_command: empty command ignored")
return
entity_id = call.data.get("entity_id")
pairs = [
(eid, manager)
for eid, manager in _ADB_COMMAND_TARGETS.items()
if entity_id in (None, eid)
]
if not pairs:
_LOGGER.warning(
"adb_command: no participating entry matches %s (is the option "
"enabled on the entry's options?)",
entity_id,
)
return
for _eid, manager in pairs:
poller = manager._poller
if poller is None or not manager.connected:
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
continue
result = await poller.shell(command)
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
def _adb_command_service_ensure(hass) -> None:
"""Register the domain service once the first participant appears."""
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
hass.services.async_register(
DOMAIN,
SERVICE_ADB_COMMAND,
_async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA,
)
def _adb_command_service_maybe_remove(hass) -> None:
"""Unregister when the last participant unloads."""
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
DOMAIN, SERVICE_ADB_COMMAND
):
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
+190
View File
@@ -0,0 +1,190 @@
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
the core port's hold_secs+direction validation quirks are not carried over —
long presses are expressed as the START_LONG/END_LONG pair instead;
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
``hold_secs`` parameter;
- ``activity`` = the protocol current-app push (package; name resolution is
deferred app-map work);
- turn on/off send the POWER key and honor the is_on push (no superfluous
toggling when the state already matches).
"""
import asyncio
from homeassistant.components.remote import RemoteEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
_START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:"
_SHORT_PREFIX = "SHORT:"
_POWER_KEY = "KEYCODE_POWER"
def _split_command(command: str) -> tuple[str, str]:
"""Key + direction from a command string (default SHORT)."""
lowered = command.strip()
if lowered.startswith(_START_PREFIX):
return lowered[len(_START_PREFIX):], "START_LONG"
if lowered.startswith(_END_PREFIX):
return lowered[len(_END_PREFIX):], "END_LONG"
if lowered.startswith(_SHORT_PREFIX):
return lowered[len(_SHORT_PREFIX):], "SHORT"
return lowered, "SHORT"
def _key_code_name(command: str) -> str:
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
if mapped is not None:
return mapped
upper = command.upper()
if upper.startswith("KEYCODE_"):
return upper
return upper # unknown names: the library reports/validates them
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add the protocol-backed remote entity for this entry."""
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
class NvidiaShieldRemoteEntity(RemoteEntity):
"""Remote entity over the manager's androidtvremote2 transport."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._manager: ShieldConnectionManager | None = getattr(
entry, "runtime_data", None
)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
def _manager_of(self) -> ShieldConnectionManager | None:
return getattr(self._entry, "runtime_data", None)
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
@property
def available(self) -> bool:
"""Unavailable while the transport is active but unpaired/down.
A passive manager (no protocol transport) keeps the entity available:
nothing to transport is not a broken one.
"""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return True
if not manager.protocol_active:
return True
return manager.is_available is not False
@property
def activity(self) -> str | None:
"""Current foreground app reported by the protocol push."""
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
return manager.current_app
return None
@callback
def _refresh_state(self) -> None:
"""Manager store changed — re-evaluate state (activity/available)."""
self._manager = self._manager_of()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
self._manager_unregistered = manager.register_listener(
self._refresh_state
)
async def async_will_remove_from_hass(self) -> None:
unregistered = getattr(self, "_manager_unregistered", None)
if unregistered is not None:
unregistered()
self._manager_unregistered = None
async def async_turn_on(self, **kwargs) -> None:
"""Wake the device with the POWER key when it reports off."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.is_on is not False:
# Unknown counts as "maybe on" — no superfluous toggle.
return
await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def async_turn_off(self, **kwargs) -> None:
"""Sleep the device with the POWER key when it reports on."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.is_on is not True:
return
await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def async_send_command(
self,
command: list[str],
num_repeats: int = 1,
delay_secs: float = 0.0,
hold_secs: float = 0.0,
) -> None:
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG)."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
for _ in range(max(1, num_repeats)):
for cmd in command:
key_part, direction = _split_command(cmd)
key_name = _key_code_name(key_part)
if hold_secs > 0 and direction == "SHORT":
if await manager.send_protocol_key(key_name, "START_LONG"):
await asyncio.sleep(max(0.0, hold_secs))
await manager.send_protocol_key(key_name, "END_LONG")
else:
await manager.send_protocol_key(key_name, direction)
if delay_secs > 0:
await asyncio.sleep(delay_secs)
async def async_send_text(self, text: str) -> bool:
"""Text entry (P5-2) via the protocol send_text (remote surface).
Falls back to ADB `input text` only when the protocol transport is
down or the send failed (the Shield may swallow IME text while its
on-screen keyboard is up, RESEARCH_IDEAS §2).
"""
manager = self._manager_of()
if manager is None or not text:
return False
if await manager.send_text_ime(text):
return True
return await manager.send_adb_input_text(text)
+42 -8
View File
@@ -4,12 +4,13 @@ import logging
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import HomeAssistant, callback, Event from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import ( from .const import (
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
@@ -60,43 +61,76 @@ class NvidiaShieldSensorBase(SensorEntity):
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield.""" """Current running app on the Shield.
Primary source: the androidtv entity's own state. When that entity is
unavailable, falls back to the ShieldConnectionManager's StateStore
(most recent visible ADB recent-task app).
"""
_attr_name = "Current App" _attr_name = "Current App"
_attr_icon = "mdi:application" _attr_icon = "mdi:application"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry) super().__init__(hass, entry)
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY] self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app" self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
def _manager(self) -> ShieldConnectionManager | None:
manager = getattr(self._entry, "runtime_data", None)
return manager if isinstance(manager, ShieldConnectionManager) else None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
self._unsub_listeners.append( self._unsub_listeners.append(
async_track_state_change_event( async_track_state_change_event(
self.hass, [self._androidtv_entity], self._handle_state_change self.hass, [self._androidtv_entity], self._handle_state_change
) )
) )
manager = self._manager()
if manager is not None:
# Refresh when the connection manager updates its store.
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
@callback @callback
def _handle_state_change(self, event: Event) -> None: def _handle_state_change(self, event: Event) -> None:
self.async_write_ha_state() self.async_write_ha_state()
@callback
def _handle_store_update(self) -> None:
self.async_write_ha_state()
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
state = self.hass.states.get(self._androidtv_entity) state = self.hass.states.get(self._androidtv_entity)
if not state: if state:
return None
return state.attributes.get("app_name") or state.attributes.get("app_id") return state.attributes.get("app_name") or state.attributes.get("app_id")
# androidtv entity unavailable — StateStore priority chain (P3-3c):
# protocol current_app push → ADB mResumedActivity → ADB recents
manager = self._manager()
if manager is not None:
foreground = manager.foreground_app
if foreground:
return foreground
running = manager.running_apps
if running:
return running[0].get("package")
return None
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
state = self.hass.states.get(self._androidtv_entity) state = self.hass.states.get(self._androidtv_entity)
if not state: if state:
return {}
return { return {
"app_id": state.attributes.get("app_id"), "app_id": state.attributes.get("app_id"),
"app_name": state.attributes.get("app_name"), "app_name": state.attributes.get("app_name"),
} }
manager = self._manager()
if manager is not None:
running = manager.running_apps
if running:
return {"app_id": running[0].get("package")}
return {}
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
+43 -9
View File
@@ -6,7 +6,7 @@
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.", "description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": { "data": {
"name": "Device Name", "name": "Device Name",
"androidtv_entity": "Android TV Remote", "androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
"jellyfin_entity": "Jellyfin Media Player", "jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver Input",
@@ -14,7 +14,9 @@
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host", "adb_host": "ADB Host",
"adb_port": "ADB Port" "adb_port": "ADB Port",
"protocol_enabled": "Pair with Nvidia Remote Protocol",
"protocol_host": "Protocol Host"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.", "androidtv_entity": "The media_player entity from the Android TV Remote integration.",
@@ -24,21 +26,43 @@
"power_entity": "Smart plug switch for reliable on/off detection.", "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_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_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." "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) \u2014 pre-filled automatically on discovery."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Pair ADB", "title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again." "description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
},
"protocol_pair": {
"title": "Pair Remote Protocol",
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
"data": {
"pairing_code": "Pairing code shown on the Shield"
}
},
"reauth_confirm": {
"title": "Re-pair Nvidia Shield",
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
} }
}, },
"error": { "error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.", "adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step." "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
"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).",
"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.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
}, },
"abort": { "abort": {
"already_configured": "This Shield entity is already configured." "already_configured": "This Shield entity is already configured.",
"reauth_successful": "Re-paired successfully.",
"reauth_entry_missing": "This entry no longer exists.",
"zeroconf_no_host": "The discovery event carried no usable address."
} }
}, },
"options": { "options": {
@@ -54,17 +78,27 @@
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host", "adb_host": "ADB Host",
"adb_port": "ADB Port" "adb_port": "ADB Port",
"app_name_map": "Apps name/icon map",
"adb_command_enabled": "Enable raw ADB shell service",
"channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Live events push (logcat)"
}, },
"data_description": { "data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.", "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.",
"receiver_entity": "The receiver this device is connected to.", "receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.", "receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.", "power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.", "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_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_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.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
} }
} }
} }
@@ -6,7 +6,7 @@
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.", "description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": { "data": {
"name": "Device Name", "name": "Device Name",
"androidtv_entity": "Android TV Remote", "androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
"jellyfin_entity": "Jellyfin Media Player", "jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver Input",
@@ -14,7 +14,9 @@
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host", "adb_host": "ADB Host",
"adb_port": "ADB Port" "adb_port": "ADB Port",
"protocol_enabled": "Pair with Nvidia Remote Protocol",
"protocol_host": "Protocol Host"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.", "androidtv_entity": "The media_player entity from the Android TV Remote integration.",
@@ -24,21 +26,43 @@
"power_entity": "Smart plug switch for reliable on/off detection.", "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_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_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." "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) \u2014 pre-filled automatically on discovery."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Pair ADB", "title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again." "description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
},
"protocol_pair": {
"title": "Pair Remote Protocol",
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
"data": {
"pairing_code": "Pairing code shown on the Shield"
}
},
"reauth_confirm": {
"title": "Re-pair Nvidia Shield",
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
} }
}, },
"error": { "error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.", "adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step." "adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
"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).",
"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.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
}, },
"abort": { "abort": {
"already_configured": "This Shield entity is already configured." "already_configured": "This Shield entity is already configured.",
"reauth_successful": "Re-paired successfully.",
"reauth_entry_missing": "This entry no longer exists.",
"zeroconf_no_host": "The discovery event carried no usable address."
} }
}, },
"options": { "options": {
@@ -54,17 +78,27 @@
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host", "adb_host": "ADB Host",
"adb_port": "ADB Port" "adb_port": "ADB Port",
"app_name_map": "Apps name/icon map",
"adb_command_enabled": "Enable raw ADB shell service",
"channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Live events push (logcat)"
}, },
"data_description": { "data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.", "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.",
"receiver_entity": "The receiver this device is connected to.", "receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.", "receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.", "power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.", "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_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_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.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
} }
} }
} }
+238
View File
@@ -0,0 +1,238 @@
# HA integration-standards audit — custom_components/nvidia_shield
Status: **COMPLETE.** Every claim is cited to a developer-docs page fetched during
this audit (October 2026; the docs site shows "Last updated" stamps of Jun–Oct 2026
on the pages cited). Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`,
`RESEARCH_IDEAS.md`.
Sources audited (all fetched live):
[Manifest](https://developers.home-assistant.io/docs/creating_integration_manifest/) (updated 2026-10-06) ·
[Config flow](https://developers.home-assistant.io/docs/core/integration/config_flow/) ·
[Options flow](https://developers.home-assistant.io/docs/core/integration/options_flow/) (updated 2026-09-17) ·
[YAML configuration](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/) (updated 2026-09-17) ·
[Config entries](https://developers.home-assistant.io/docs/config_entries_index/) (updated 2026-06-19) ·
[Entity](https://developers.home-assistant.io/docs/core/entity/) ·
[Internationalization](https://developers.home-assistant.io/docs/internationalization/core/) ·
[Quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) + [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/) ·
[Custom actions](https://developers.home-assistant.io/docs/dev_101_services/) ·
[Diagnostics](https://developers.home-assistant.io/docs/core/integration/diagnostics/) ·
[Tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/) ·
[Deprecating (incl. YAML-drop pattern)](https://developers.home-assistant.io/docs/deprecating/) ·
[ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) ·
[Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/) ·
[Listener+reload deprecation blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/) ·
[BrowseMediaSource domain blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/) ·
[Central config-flow abort reasons](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) ·
[Always reload after reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) ·
[HACS publish requirements](https://hacs.xyz/docs/publish/integration/)
Code audited: `manifest.json`, `strings.json`, `translations/en.json`,
`__init__.py`, `config_flow.py`, `connection.py`, `media_player.py`,
`remote.py`, `sensor.py`, `button.py`, `diagnostics.py`, `helpers.py`, tests/,
repo root (`hacs.json`, README).
Legend: ✅ compliant · ⚠️ partial / should improve · ❌ gap (concrete fix in §3).
---
## Part 1 — Compliance checklist
### 1.1 Manifest ([docs](https://developers.home-assistant.io/docs/creating_integration_manifest/))
| Requirement | Us | Notes |
|---|---|---|
| `domain`, `name` naming rules | ✅ | `nvidia_shield` / "NVIDIA Shield" — local-only product, no suffix, per manifest doc's name rules |
| `version` required for custom integrations (AwesomeVersion-compatible) | ✅ | `2026.10.08` (CalVer) — "The version of the integration is required for custom integrations" |
| `documentation` URL | ✅ | points to the repo; for core submission it must be home-assistant.io (not applicable yet) |
| `issue_tracker` | ✅ | present (HACS also lists it as required: [HACS publish](https://hacs.xyz/docs/publish/integration/)) |
| `codeowners` | ✅ | `@notexpectedyet` |
| `iot_class` from allowed set | ✅ | `local_push` — valid enum value; correct now that both the protocol push and the persistent ADB manager are the primary paths |
| `config_flow: true` + `config_flow.py` exists | ✅ | |
| `integration_type` | ⚠️ | **Not set.** Required for core integrations with a config flow; for custom it defaults to `hub` — but we are a single-device-per-entry integration, so it "should generally" be `device`. Explicit is recommended ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)). Gap G1 |
| `requirements` | ✅ | `adb-shell>=0.4.0`, `androidtvremote2>=0.2.0`; "Custom integrations should only include requirements that are not required by Core" — both are non-core deps. Range-style (`>=`) is accepted for custom integrations (hassfest pins core integrations to `==`; range bounds are the pragmatic custom-integration choice) |
| `dependencies` | ✅ | `[]` — we read Jellyfin state via the *already-configured* jellyfin integration and discover via the user's zeroconf service; neither is required to be a `dependencies` entry (manifest doc: dependencies guarantee the integration loads before setup, not that entries exist). G4 below covers the Jellyfin soft-dependency behavior if we ever import its API |
| `zeroconf` discovery key | ✅ | `_androidtvremote2._tcp.local.` — exact-service-type entry, not in the "too generic" list |
| `single_config_entry` | n/a | Multiple Shields are legitimate — do NOT set ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| `loggers` | ⚠️ | Not set. Recommended so users can raise `adb_shell`/`androidtvremote2` logging under our domain view; `adb_shell` (we wrap) and `androidtvremote2` are exactly the `getLogger` namespaces of our requirements. Gap G1 |
| `quality_scale` | n/a | Only meaningful for core integrations; custom integrations can still self-audit (§1.9) but shouldn't fake the key ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| `import_executor` | ✅ | default (true) is right for us |
| `disabled` | n/a | |
### 1.2 Config flow ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/))
| Requirement | Us | Notes |
|---|---|---|
| Extends `ConfigFlow, domain=DOMAIN` in `config_flow.py` | ✅ | |
| `VERSION` (+ migration for old entries) | ✅ | `VERSION = 2` with `async_migrate_entry` handling v1→v2 and rejecting downgrades (`config_entry.version > 2 → False`) — matches the migration contract ("return False → migration_error") |
| Unique IDs: stable, string, not user-changeable; `_abort_if_unique_id_configured` | ⚠️ | Protocol path uses the **MAC from the server certificate** (accepted source, [docs list](https://developers.home-assistant.io/docs/core/integration/config_flow/)). But the fallback unique_id is the **`androidtv_entity` entity_id** — entity IDs are user-renameable, which makes them a weak unique_id source (docs list "Device Name / hostname that can be changed" as unacceptable; a renameable entity reference is the same class of problem). Gap G2 |
| Discovery (zeroconf): set unique ID, abort `already_configured`, update host on IP change | ✅ | `async_step_zeroconf` sets MAC unique_id, aborts duplicates, and updates `protocol_host`/blank `adb_host` on IP change — this is the [gold `discovery-update-info`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery-update-info) behavior already implemented |
| Discovery steps must never finish without user confirmation | ✅ | first-time discovery chains into the user step |
| Discovery without unique ID → `_async_handle_discovery_without_unique_id()` | ❌ | not used on the no-MAC path; gap G2 (combine with the unique-id fallback fix) |
| `is_matching` / `async_has_matching_flow` for ambiguous discovery | ✅-ish | MAC unique_id set in the flow aborts concurrent flows for the same device (docs: "the flow will immediately abort if another flow is in progress for this unique ID"); the MAC-less path is the only exposure (G2) |
| Errors/aborts via strings keys (`errors["base"]`, `async_abort(reason=...)`) | ✅ | all keys exist in `strings.json` config.error/abort |
| Interactive pairing (PIN) via flow steps | ✅ | `protocol_pair` uses `async_show_progress`-free two-step pattern (PIN prompt); acceptable — `async_show_progress`/`async_show_progress_done` are for long-running background work |
| Reauth flow | ⚠️ | Steps exist (`async_step_reauth`/`reauth_confirm`), triggered via `entry.async_start_reauth(hass)` from the manager's invalid-auth callback ([config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/): reauth must update the entry and abort, not create). But `_finish_reauth` uses `async_update_entry` + `async_schedule_reload` — see G3 (deprecated combo) and [always-reload-after-reauth blog](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/); prefer `async_update_reload_and_abort(self._get_reauth_entry(), ...)`, and reauth flows need to be linked to a config entry ([2024-11-04 blog](https://developers.home-assistant.io/blog/2024/11/04/reauth-reconfigure-entry-id/) — ours gets it via `self.context["entry_id"]`, the mechanism the blog's helper replaces) |
| Reconfigure step | ❌ | No `async_step_reconfigure`. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) says a reconfigure step lets users change **data** that is "not meant to be optional" without an OptionsFlow, and it is a [gold rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow). Our hosts live in options, so we are not broken — but G5 documents what a reconfigure step should own (device name + host moves + source toggles) |
| Config-entry mutation only via `async_update_entry` | ✅ | everywhere ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/)) |
| Full test coverage of config_flow.py ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/): "Integrations with a config flow require full test coverage of all code in config_flow.py") | ⚠️ | tests exist (`test_config_flow_pairing.py`, `test_config_flow_protocol.py`) but "full coverage" (also [bronze rule `config-flow-test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage)) needs a coverage run to confirm every step/branch; G6 |
### 1.3 Options flow ([docs](https://developers.home-assistant.io/docs/core/integration/options_flow/))
| Requirement | Us | Notes |
|---|---|---|
| `async_get_options_flow(config_entry) -> OptionsFlow()` **without constructor args**; entry via `self.config_entry` | ❌ | Ours: `NvidiaShieldOptionsFlow(config_entry)` stores `self._config_entry` — the old pattern. Current docs' handler takes **no arguments** and reads `self.config_entry`. Gap G3 |
| Prefill via `self.add_suggested_values_to_schema(SCHEMA, self.config_entry.options)` | ⚠️ | We hand-roll `vol.Optional(key, default=current)` per field — works, but the documented helper is the single schema + suggested-values pattern; also lets us keep ONE static schema instead of conditionally building keys (ours rebuilds the schema dict each render, which also means fields disappear/reappear depending on state — legal but harder to test). G3 |
| Update listener: `entry.async_on_unload(entry.add_update_listener(...))` | ✅ | registered in `async_setup_entry` ([options doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)) |
| Reload-on-options: `OptionsFlowWithReload` OR update listener — **not both** | ❌ | We use the update listener (full reload on every options change, including pure-UI tweaks like app map); the current docs offer `OptionsFlowWithReload` for exactly this. Combined with G3 (the flow's own `async_schedule_reload` calls in reauth/zeroconf) this is the deprecated [listener+reloading-methods combo (error from 2026.12)](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/). Gap G3 |
### 1.4 strings.json / translations ([docs](https://developers.home-assistant.io/docs/internationalization/core/))
| Requirement | Us | Notes |
|---|---|---|
| `strings.json` adjacent to integration; `translations/en.json` for custom | ✅ | both present, same content |
| `config` (steps/errors/aborts), `options` sections | ✅ | complete for every key we return |
| Common-string references (`[%key:common::...%]`) | ⚠️ | e.g. `already_configured`, `reauth_successful` are common abort reasons — now [translated centrally](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) for shared reasons; harmless duplicated, minor cleanup |
| `title` key | ⚠️ | optional ("fallback to manifest name"); omitted is fine |
| `entity` section with `translation_key` on named entities | ⚠️ | Sensors use hard-coded English names (`_attr_name = "Current App"`, `"Now Playing"`). Entity doc: "Avoid hard coding a natural language name, use a translated name instead"; [gold rule `entity-translations`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations). Gap G7 |
| `exceptions` section for `HomeAssistantError` translations | ❌ | we raise/log instead of raising translated exceptions in the action handler (see G8; also [raising exceptions](https://developers.home-assistant.io/docs/core/platform/raising_exceptions/) pattern) |
| `services` section + `services.yaml` | ❌ | `nvidia_shield.adb_command` is registered dynamically with a schema but has **no `services.yaml` and no `services` strings** — see G8. Note [2025-10-24 blog](https://developers.home-assistant.io/blog/2025/10/24/service-web-api-changes/): service translations were removed from the WebSocket/REST service list — descriptions now live only in `services.yaml`/strings, making them required for frontend display |
| `issues` section (repairs) | n/a | no repair issues yet (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues) — G9 suggestion for the ADB-unpaired state) |
### 1.5 Entity standards ([Entity doc](https://developers.home-assistant.io/docs/core/entity/))
| Requirement | Us | Notes |
|---|---|---|
| `has_entity_name = True` ([mandatory for new integrations](https://developers.home-assistant.io/docs/core/entity/)) | ✅ | all entities |
| Main-feature entity: `_attr_name = None` (friendly_name = device name) | ✅ | media_player and remote |
| Sub-entities named without device/type prefix | ✅-ish | "Current App", "Now Playing", "Pair ADB" — right shape, but hard-coded (G7) |
| `unique_id` on every entity ([bronze `entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id)) | ✅ | `{DOMAIN}_{entry.entry_id}_{key}` — stable, not user-configurable |
| `device_info` on all entities (automatic device registration) | ✅ | every platform; one device per entry keyed by `entry.entry_id`. ⚠️ model is hard-coded "Shield TV" — the protocol's `device_info` (model/vendor/sw_version) is available and populating `model`/`model_id`/`sw_version` from it is a cheap upgrade ([`model_id` added 2024-07](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)). G10 |
| `should_poll` — push entities notify via `async_schedule_update_ha_state` / write | ✅ | `_attr_should_poll = False` everywhere + explicit `async_write_ha_state` on store events ([entity doc, subscribing pattern](https://developers.home-assistant.io/docs/core/entity/)) |
| `available` ([silver `entity-unavailable`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unavailable)) | ✅ | media_player/remote/sensors derive from manager health |
| Feature flags via `MediaPlayerEntityFeature` enum (no magic numbers — [deprecation](https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation/)) | ✅ | TURN_ON|TURN_OFF|VOLUME_STEP|VOLUME_MUTE|PLAY_MEDIA|BROWSE_MEDIA + playback features; **no `VOLUME_SET`** — correct per the protocol's capability ([RESEARCH_IDEAS §2 C-VOL](RESEARCH_IDEAS.md)) |
| States via current `MediaPlayerState` enum | ✅ | imports the component enum; `STANDBY` not used ([deprecated 2025-07](https://developers.home-assistant.io/blog/2025/07/16/media-player-standby-state-deprecation/)) — our asleep→IDLE mapping is the compliant choice |
| `entity_category` ([gold `entity-category`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) | ⚠️ | "Pair ADB" button is a device-configuration action → `EntityCategory.CONFIG` is the documented fit; sensors are primary. G11 |
| `entity_registry_enabled_default` for noisy entities ([gold](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-disabled-by-default)) | ⚠️ | Now Playing is chatty (per-track state changes); consider `False` for the least-used mirror sensors only — judgment call, low priority |
| `_unrecorded_attributes` for frequently-changing attributes ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | `extra_state_attributes` (media_player.py:804) carries session/transport details; media_player already unrecords its domain attributes, but our custom attributes that change with every poll should be listed. G12 |
| Icon property discouraged vs icon translations ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | static `_attr_icon` is the discouraged-but-legal form; app-art uses `entity_picture` (fine). Icon translations only matter for gold |
### 1.6 Setup / unload / lifecycle ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/))
| Requirement | Us | Notes |
|---|---|---|
| `entry.runtime_data` for runtime objects ([bronze `runtime-data`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data)) | ✅ | `ShieldConnectionManager` on `entry.runtime_data` |
| `async_forward_entry_setups` + platform `async_setup_entry` | ✅ | |
| `async_unload_entry` forwards unload + cleans up listeners/connections | ✅ | manager stopped, hass.data popped; entities unsubscribe in `async_will_remove_from_hass` |
| `EVENT_HOMEASSISTANT_STOP` closes connections | ✅ | via `entry.async_on_unload(bus.async_listen_once(...))` |
| `hass.data[DOMAIN][entry_id]` usage | ⚠️ | we still store the entry there for cross-module lookups; fine, but the manager on `entry.runtime_data` makes most consumers redundant — trim opportunistically |
| Test-before-setup ([bronze](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup)) | ⚠️ deliberate | We intentionally never raise `ConfigEntryNotReady` (ADB may be unpaired; manager retries in background). This is a justified deviation — the rule's goal (avoid "unknown error" states) is met by the passive-start design + Pair ADB button + protocol `keep_reconnecting` — but it should be **documented** (e.g. a `quality_scale.yaml`-style note or README paragraph). G13 |
### 1.7 Actions (services) ([docs](https://developers.home-assistant.io/docs/dev_101_services/))
| Requirement | Us | Notes |
|---|---|---|
| Register actions in `async_setup` — "Service actions should always be registered" so they exist even with no loaded entries | ❌ | `adb_command` is registered lazily from the media_player platform when the first entry opts in, and removed when the last unloads. That violates the documented posture (always registered; the doc's rationale: editable/validatable automations + informative errors). Gap G8 |
| `services.yaml` describing fields/target | ❌ | missing entirely (G8) |
| Names/descriptions from translations ([i18n doc](https://developers.home-assistant.io/docs/internationalization/core/), `services` key) | ❌ | none (G8) |
| Target the right level; **target must not be optional** ("Do not implement a default target when none is specified") | ⚠️ | our schema has `Optional("entity_id")` with implicit all-entries fallback — non-compliant targeting. G8 |
| Errors raised as exceptions with translations ([silver `action-exceptions`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions); [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) | ❌ | logs warnings on failure instead of raising `ServiceValidationError(translation_domain=...)`. G8 |
### 1.8 Diagnostics ([docs](https://developers.home-assistant.io/docs/core/integration/diagnostics/))
| Requirement | Us | Notes |
|---|---|---|
| `async_get_config_entry_diagnostics` | ✅ | correct module-level function (diagnostics.py — previously misplaced inside media_player.py and fixed per t20/t21 finding) |
| Redaction of sensitive data via `async_redact_data` | ⚠️ | we ship a hand-rolled `_redact` with marker matching; the doc points at the `async_redact_data` helper with an explicit TO_REDACT list — the helper is the convention (defensive marker-redaction is a reasonable extra on top, but config fields like host should use the helper list). G14 |
| Device diagnostics ([gold `diagnostics`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics)) | ⚠️ | config-entry only; device diagnostics is the gold-tier add (single-device integration → mostly redundant, note why if skipped) |
### 1.9 Tests ([tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/))
| Requirement | Us | Notes |
|---|---|---|
| Tests per domain under `tests/`, `conftest.py`, `test_init.py` for setup/reload/unload | ⚠️ | `tests/` exists with focused files; there is no `test_init.py` covering setup/reload/unload/migration of the entry (the structure doc names `test_init.py` for exactly that). G6 |
| Config-flow full coverage | ⚠️ | see G6 |
| Coverage bar for silver: >95% ([`test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage)) | ⚠️ | run pytest --cov to baseline; vendored `ha_adb_common` counts toward it |
### 1.10 Documentation / quality scale ([quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/), [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/))
**Bronze** (baseline for all new integrations): config-flow ✅ · entity-unique-id ✅ · has-entity-name ✅ · runtime-data ✅ · test-before-configure ✅ (both pairing steps test the connection) · unique-config-entry ✅ (MAC path) · action-setup ❌ (G8) · config-flow-test-coverage ⚠️ (G6) · brands ⚠️ (G15: no `brand/` icon in repo; HACS requires brand assets) · docs rules (G16: README has setup docs; needs explicit removal instructions + action descriptions to satisfy `docs-high-level-description`, `docs-installation-instructions`, `docs-removal-instructions`, `docs-actions`).
**Silver** adds: config-entry-unloading ✅ · reauthentication-flow ✅ (with G3 fix) · entity-unavailable ✅ · log-when-unavailable ⚠️ (verify we log once per transition, not every retry — the manager's backoff looks right; add a test) · parallel-updates ⚠️ (entities are push; declare/verify `PARALLEL_UPDATES` where the platform defaults matter — G12 note) · action-exceptions ❌ (G8) · test-coverage ⚠️ (G6) · docs-configuration-parameters ⚠️ (README should describe every option; strings.json is the source of truth today) · integration-owner ✅ (codeowner declared).
**Gold** adds (for reference, not required): discovery ✅ (+update-info ✅) · devices ✅ · diagnostics ✅ · entity-category/entity-translations/icon-translations ❌ (G7/G11) · reconfiguration-flow ❌ (G5) · repair-issues ❌ (G9) · extensive docs ❌ (G16).
## Part 2 — Optional YAML configuration alongside the config flow: current doctrine and canonical pattern
### 2.1 The rule
- [ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) (binding): *"Integrations that communicate with devices and/or services are only configured via the UI. In rare cases, we can make an exception."*
- Current [YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/): same rule restated — *"Integrations that communicate with devices and/or services are configured via a config flow. In rare cases, we can make an exception. Existing integrations that should not have a YAML configuration are allowed and encouraged to implement a configuration flow and remove YAML support. **Changes to existing YAML configuration for these same existing integrations will no longer be accepted.**"* Also: *new integrations must not use platform-key YAML* (ADR-0007); any YAML must live under the integration's own domain key.
- So: for our device integration, YAML is allowed **only** as an *import source* that ends in a real config entry (`source: import`), never as a parallel runtime configuration path. The `import` step name is [reserved for exactly this](https://developers.home-assistant.io/docs/core/integration/config_flow/).
### 2.2 The as-documented pattern ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/), "Dropping YAML support for an integration")
The current docs' canonical wiring (shown there in the context of deprecating YAML, and it is the same seeding mechanism an *optional* import uses):
1. **`__init__.py`: `async_setup(hass, config)` reads the domain key and seeds the import flow.** The exact documented snippet:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the integration."""
if DOMAIN not in config:
return True
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
...
```
(`SOURCE_IMPORT` from `homeassistant.config_entries`; this is the current documented form — the deprecated `discovery` step and platform-key imports are gone.)
2. **`config_flow.py`: `async_step_import` (step name `import`) validates the YAML dict, reuses the same entry-creation path as `async_step_user` (unique_id, `_abort_if_unique_id_configured`, `_create_entry`).** Resulting entries carry `source: import` and behave exactly like UI entries thereafter — options flow, reload, unload all apply. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) reserves the step name; [config entries doc](https://developers.home-assistant.io/docs/config_entries_index/) treats imported entries as normal config entries.
3. **Add a `CONFIG_SCHEMA`** for the domain-key YAML ([YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/), CONFIG_SCHEMA section) using probatio/vol schema, and re-use the config-flow validation so YAML and UI stay consistent.
4. If YAML support is ever *dropped* (not our case), the documented procedure is: import step + the core `deprecated_yaml` repair issue with `HOMEASSISTANT_DOMAIN`, `breaks_in_ha_version`, then leave `cv.config_entry_only_config_schema(DOMAIN)` behind ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/)).
### 2.3 What cannot be imported (honest limits)
- **Interactive pairing cannot happen during import.** An import step produces a config entry from static YAML data — it cannot wait for a human to approve an ADB prompt or type a PIN shown on the Shield. Our design already anticipates this: **ADB key approval is intentionally decoupled from setup** (the manager auto-connects only when a key exists; the Pair ADB button + flow step handle approval), so an imported entry can start unpaired and become functional after the user pairs. For the **protocol (PIN) pairing** there is currently no post-setup trigger — an imported entry with `protocol_enabled` cannot complete pairing without re-running the config flow. Options: keep protocol pairing out of the YAML surface (recommended), or add a repair issue/button that starts a reauth-style protocol-pair flow for unpaired entries (G9).
- **Discovery-derived values (MAC unique_id) cannot come from YAML.** The import can set a *user-chosen* unique_id (e.g. the entity reference) or none; a MAC-less entry weakens duplicate-protection (see G2) — the import should require the `androidtv_entity` reference or an explicit `unique_id`-grade value.
- **`source: import` entries show in the UI** and can be migrated to `source: user` only by deletion + re-add; document that YAML edits require deleting/re-importing (or honoring `async_update_entry` on re-import with `reason: options_updated` style updates — simplest is abort `already_configured` on re-import, matching most core integrations).
### 2.4 HACS / custom-integration policy view
- [HACS publish requirements](https://hacs.xyz/docs/publish/integration/) do not mention YAML at all — they require repo structure (one integration under `custom_components/`), the six manifest keys (we have all: domain, documentation, issue_tracker, codeowners, name, version), and **brand assets** (an `icon.png` under `brand/` or the brands repo — we currently lack it, G15). HACS never *forbids* an import path; it is simply orthogonal.
- The HA project position ([ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md)) is that contributors may drop YAML to reduce burden "power to our contributors" — i.e. no project-level pressure to provide YAML for device integrations, and the quality scale contains no YAML rule. **Recommendation: treat YAML import as optional sugar; do not build it for the MVP** — the UI flow already covers discovery + pairing, and the import path buys only static-foreignKey convenience at the cost of the G2/G9 caveats.
## Part 3 — Gap list (concrete, file-anchored)
Ordered by severity; each item names the file(s) and the change.
| # | Severity | Gap → Fix |
|---|---|---|
| G3a | **High (breaks 2026.12)** | Deprecated listener+reloading combo: `config_flow.py` `_finish_reauth` (`async_update_entry` + `async_schedule_reload`) and the zeroconf host-update path (`async_update_entry` + `async_schedule_reload`) both double-reload because `__init__.py` registers `entry.add_update_listener(_async_update_listener)` (full reload). Per the [2026-05-07 blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/): in `config_flow.py` replace reauth's pair with `self.async_update_reload_and_abort(self._get_reauth_entry(), data_updates={...})`; for zeroconf use `async_update_entry` only and let the listener reload (or `reload_on_update=False` on `_abort_if_unique_id_configured`). Also see [always-reload-after-reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) |
| G3b | **High (deprecation)** | `config_flow.py` `NvidiaShieldOptionsFlow(config_entry)`: constructor must take no args — `async_get_options_flow(config_entry) -> NvidiaShieldOptionsFlow()` reading `self.config_entry` ([options flow doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)); convert hand-rolled defaults to `add_suggested_values_to_schema`; consider `OptionsFlowWithReload` and dropping the update listener (or keep listener for partial reloads of non-structural options — but then never schedule reloads inside the flow) |
| G8 | High (silver) | `media_player.py` `_adb_command_service_ensure/_maybe_remove` + missing files: move registration to `__init__.py` `async_setup` (always registered, with `option-dependent` handler that no-ops/logs when no entry opted in); add `services.yaml` (fields: `command` required, `entity_id` **required** target — [targeting rules](https://developers.home-assistant.io/docs/dev_101_services/): target must not be optional; consider `device_id`/config_entry targeting instead of entity); add `strings.json` `services` section (name/description/fields) + `icons.json` service icon; replace warning-logs with `ServiceValidationError(translation_domain=DOMAIN, translation_key=...)` + `exceptions` section ([action-exceptions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions), [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) |
| G2 | High | Unique-ID source fallback: `config_flow.py` uses `androidtv_entity` (entity_id) as unique_id when no MAC exists — renameable/user-changeable → weak. Fix: on the protocol path always MAC; on ADB-only setups without protocol, use `await self._async_handle_discovery_without_unique_id()`-grade handling for discovery, and for manual setup keep the entity-based ID but document the rename caveat, or derive from the device's MAC via the entity's device registry entry (device registry holds `CONNECTION_NETWORK_MAC`) |
| G1 | Medium | `manifest.json`: add `"integration_type": "device"`, `"loggers": ["adb_shell", "androidtvremote2"]` ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| G6 | Medium | Tests: add `tests/test_init.py` (setup/reload/unload/migration) per the [tests structure doc](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/); run coverage against `config_flow.py` to satisfy [config-flow-test-coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage) |
| G7 | Medium | Entity naming: sensors `sensor.py` "Current App"/"Now Playing" — replace `_attr_name` with `translation_key` + `entity.sensor.*` names in `strings.json` ([entity naming](https://developers.home-assistant.io/docs/core/entity/), [entity-translations](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations)); button "Pair ADB" likewise |
| G9 | Medium | Unpaired/needs-pairing UX: raise a repair issue (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues)) with an `issues` strings section when the protocol transport is enabled but unpaired, linking to the reauth/pair flow — this also gives YAML-imported entries a pairing path (see §2.3) |
| G10 | Low | `media_player.py` `device_info`: populate `model`/`model_id`/`sw_version` from the protocol `device_info` push (available post-pairing; [model_id blog](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)) |
| G14 | Low | `diagnostics.py`: switch `_redact` to `async_redact_data` with an explicit TO_REDACT list for `entry.data/options` ([diagnostics doc](https://developers.home-assistant.io/docs/core/integration/diagnostics/)), keeping the marker-based defense as a second layer |
| G11 | Low | `button.py`: `_attr_entity_category = EntityCategory.CONFIG` on Pair ADB ([entity-category rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) |
| G12 | Low | `media_player.py`: declare `_unrecorded_attributes` (class attribute) for fast-changing custom attributes; verify silver `log-when-unavailable` once-per-transition behavior in `connection.py` with a test ([entity doc](https://developers.home-assistant.io/docs/core/entity/), [log-when-unavailable](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/log-when-unavailable)) |
| G16 | Low | Docs for bronze: README needs a removal section + per-action documentation ([docs-removal-instructions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-removal-instructions), [docs-actions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-actions)) |
| G15 | Low | Brand assets: add `brand/icon.png` to the repo (HACS requirement, [HACS publish](https://hacs.xyz/docs/publish/integration/)) and add the integration to the [brands repo](https://developers.home-assistant.io/docs/creating_integration_brand/) |
| G17 | Info | Schema library: `config_flow.py`/`media_player.py` import `voluptuous` — **no change required** ([Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/): HA aliases the import; voluptuous is no longer installed but the alias resolves; the ban is core-only). New code may import `probatio` directly; opportunistically migrate |
| G18 | Info | BrowseMedia: `media_player.py` `async_browse_media` constructs `BrowseMediaSource` without `domain` — `domain` is now a **required** `str` on `BrowseMediaSource.__init__` ([2026-05-20 blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/)). Verify the media_player-component helper we import and pass `domain=DOMAIN` (children) / the integration domain (root); our `media_content_type="apps"` root is not a `RootBrowseMediaSource` case |
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
| G4 | Low | Jellyfin auto-detect silently fails when the Jellyfin integration is not yet configured (`config_flow.py` `_find_jellyfin_entity` finds no entities). We deliberately do not list `jellyfin` in `dependencies` (nothing breaks without it); document the ordering caveat in the README instead ([manifest doc, dependencies semantics](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| G13 | Low | Document the deliberate deviation from [test-before-setup](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup): a README paragraph (or an exempted-rule note) explaining that entries start passively without `ConfigEntryNotReady` because ADB may be unpaired, with the Pair ADB button + protocol `keep_reconnecting` + reauth as the recovery UX |
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
### Explicitly OK as-is (no action)
- `iot_class: local_push`, requirement ranges, zeroconf entry, `version` (CalVer), `config_flow`, repo structure for HACS (one integration dir), `hacs.json`, devices per entry, feature-flag enums, `MediaPlayerState` usage, `entry.runtime_data`, unload/stop lifecycle, migration versioning shape, entity availability model, and the deliberate no-`ConfigEntryNotReady` design (document it, G13).
- Voluptuous imports remain valid through the probatio alias (G17).
+32 -15
View File
@@ -110,13 +110,17 @@ make logcat a true push stream over `adb_shell` for MVP.**
### Why not pure logcat push (yet) ### Why not pure logcat push (yet)
`adb_shell`'s `AdbDeviceTcpAsync.shell()` is **request → full-response**; there is no CORRECTION (C-STREAM, spike-verified on our pinned range): `adb_shell` 0.4.4
incremental/streaming read API on the stable surface. Running `exec logcat` blocks **does** ship `streaming_shell` — a generator yielding each line of output — on
forever. A true stream means either hacking adb_shell internals (fragile across its both the sync `AdbDevice` and async `AdbDeviceAsync` (async needs the tiny
releases) or adding a dependency (violates constraints). Also, logcat does not `aiofiles` + `async_timeout` extras, both already HA-ecosystem staples). So a
reliably carry *playback state* changes for third-party apps — it reliably carries true logcat push stream needs neither internals hacks nor a new major
**foreground/app lifecycle** events (verified R8: `ActivityTaskManager: START u0 dependency. What keeps the hybrid design honest is *device behavior*, not the
{… cmp=<pkg>/<activity>}` on every app open, including home-button → launcher). 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) ### The hybrid design (target architecture)
@@ -128,8 +132,10 @@ entry.runtime_data = ShieldConnectionManager
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command ├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap) ├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
├─ trigger listener: foreground-change events ├─ trigger listener: foreground-change events
│ (logcat -d -T1 ActivityTaskManager delta snapshots ~2–5 s cadence; ├─ trigger listener: foreground-change events
│ Phase 3 option: internals streaming if adb_shell ever supports it) │ 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) ├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
│ → if changed → `dumpsys media_session` fetch │ → if changed → `dumpsys media_session` fetch
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net) ├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
@@ -267,10 +273,14 @@ Files:
- `remote.py` (new platform) — protocol-backed remote entity (port of core: - `remote.py` (new platform) — protocol-backed remote entity (port of core:
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity = SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
current app). current app).
- `media_player.py` — command routing switches to protocol keys; volume gains - `media_player.py` — command routing switches to protocol keys; volume gains a
absolute level (VOLUME_SET) + real mute state; availability and is_on come 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 → from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
plug off. plug off.
plug off.
- `media_player.py` `async_play_media` — §7 channel port via protocol - `media_player.py` `async_play_media` — §7 channel port via protocol
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent `send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
fallback when the protocol is down. fallback when the protocol is down.
@@ -329,16 +339,23 @@ tests cover all ladder branches.
Files: Files:
- Deep links — now cheap: `media_type: app/url` → protocol - Deep links — now cheap: `media_type: app/url` → protocol
`send_launch_app_command` (port of core pattern) + BrowseMedia app directory; `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 per-app `am start` extras via ADB only for apps the protocol can't launch
(data-driven map in const). (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 - Options: apps name/icon map editor (core androidtv_remote pattern), digit
delay, IME toggle, privacy scope; finish config entry migration if needed. delay, IME toggle, privacy scope; finish config entry migration if needed.
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on - Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
IP change; `manifest.json` gains the `zeroconf` integration dependency. IP change; `manifest.json` gains the `zeroconf` integration dependency.
- `adb_command` service + diagnostics (port androidtv patterns). - `adb_command` service + diagnostics (port androidtv patterns).
- Optional: logcat streaming via adb_shell internals if verified stable (behind - Optional: streaming_shell logcat stream as the trigger source (library
an option flag); otherwise keep snapshot approach. 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 - Trim: fold Current App sensor if StateStore covers it; drop
`running_apps_from_sessions` legacy; remove transitional external-entity code. `running_apps_from_sessions` legacy; remove transitional external-entity code.
+180
View File
@@ -0,0 +1,180 @@
# Research ideas for ha-nvidia-shield (features, patterns, TiviMate)
Status: **COMPLETE.** Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`.
Method: primary sources first — library/integration source code (androidtvremote2,
adb_shell, HA core), HA core GitHub issues/PRs, home-assistant.io integration docs,
home-assistant.io action docs, and community threads. Citations are inline markdown
links. Note: HA's Action *classes* (`MediaPlayerEntityFeature.*`) are what we set in
code; HA's *Actions* (`media_player.play_media` etc.) are what users call.
---
## 1. What users actually request (from HA core issues, community threads, library issues)
| # | Request | Source | Take for our MVP |
|---|---|---|---|
| U1 | Absolute volume set on Android TV players — `media_player.volume_set` repeatedly requested; HA core androidtv_remote closed it **not_planned** | [core#176505](https://github.com/home-assistant/core/issues/176505) | High demand, but the protocol v2 proto has **no usable volume-set primitive shipped** (it reports volume; see C-VOL). Our Phase 2 now ships `VOLUME_STEP|MUTE` + level readout (captain accepted; plan already fixed). If demand ever recurs, the honest fallback is an ADB `input keyevent KEYCODE_VOLUME_*` step loop behind a helper script — **not** a fake supported feature |
| U2 | Launching *any* installed app (not just deep-linkable ones) and retrieving the installed-app list | [androidtvremote2#17](https://github.com/tronikos/androidtvremote2/issues/17) | The official NVIDIA Shield app *does* retrieve the app list over another service; androidtvremote2 accepted `market://launch?id=<pkg>` as the app-launch workaround (now built into `send_launch_app_command`, see C-LAUNCH). App-list *retrieval* is not implementable through this client's protocol surface — a data-driven, options-defined app map is the right MVP substitute (already in plan) |
| U3 | Text/IME entry (typing into search boxes); behavior **depends on the app and on whether the on-screen keyboard is open** (one-at-a-time chars get replaced in Spotify; whole-string send works) | [androidtvremote2#32](https://github.com/tronikos/androidtvremote2/issues/32) | Library supports `send_text` but behavior is app-dependent. Keep the IME option **default off** (as planned) and document the qualifier rather than trying to be clever |
| U4 | Voice search / voice commands | [androidtvremote2#28](https://github.com/tronikos/androidtvremote2/issues/28) — closed; the library now ships `start_voice()`/`VoiceStream`, gated by `enable_voice` and device feature support | Implemented in the lib (PCM 16-bit mono 8 kHz streamed as `RemoteVoiceBegin/Payload/End`). Post-MVP only; note exists so Phase 4 does not re-research |
| U5 | Deep-link launching of apps into specific sections (community guide table with ~60 known app URLs, plus how to discover one via `adb shell cmd package dump PACKAGE` and manifest string resolvers) | [deep-link guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) | Directly validates Phase-4 deep links; `send_launch_app_command` accepts either a deep link or a bare package id (see C-LAUNCH). The guide's discovery recipe (`cmd package dump <pkg>`, grep `<data`, resolve `@string/deeplink_schema`) is worth copying into our docs/contributor workflow |
| U6 | BrowseMedia containing installed apps / activity list so users can launch apps from the media panel instead of scripts | [core#117126](https://github.com/home-assistant/core/pull/117126) (androidtv_remote: "Implement Android TV Remote browse media with apps and activity list") | Validates Phase-4 BrowseMedia app directory; can be built from our own running_apps data or an options-defined list (no protocol app-list needed) |
| U7 | Channel-by-number play_media + channel up/down keys on streaming players (Roku, Bravia) | [Roku docs](https://www.home-assistant.io/integrations/roku/), [Bravia docs](https://www.home-assistant.io/integrations/braviatv/) | `play_media(media_content_type: channel)` is the canonical way every TV integration does tuning (Roku, Bravia); our §7 digit path matches. Bonus Roku/Bravia idea: expose `channel_up`/`channel_down` as remote commands (protocol `KEYCODE_CHANNEL_UP=166` / `KEYCODE_CHANNEL_DOWN=167` — in the proto, no new work) |
| U8 | TiviMate: start it and switch to a specific channel from an HA automation | [community thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) (2025, active) | **Exactly our §7 play_media(channel) requirement.** The working recipe there: `remote.turn_on` with `activity: ar.tvplayer.tv` → delay ~7 s (app cold-start) → `media_player.play_media` with `media_content_type: channel` / `media_content_id: "1"` — note the wait; our play_media should not promise cold-start-to-channel in one shot (we don't own app launch speed). Follow-up request in the thread shows demand for tuning VOD/series pages too (unresolved anywhere) |
| U9 | Shield app-launcher cards for wall-panel remotes (Firemote adds TiviMate/SmartTube shortcuts); Harmony-style activity switching | [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48), [Shield launcher blueprint](https://community.home-assistant.io/t/shield-app-launcher-for-harmony-remote/858225) | QoL signal for Phase-4 apps editor + `source_list`; also shows app-name→icon mapping is what users iterate on (supports data-driven app map + icon support) |
| U10 | Firemote / universal-remote-card users must hand-define every app shortcut because the Shield integration exposes no app directory | [universal-remote-card discussion](https://github.com/Nerwyn/universal-remote-card/discussions/52) | Independent confirmation of U6/U9 demand; [TiviMate + SmartTube shortcut request](https://github.com/PRProd/HA-Firemote/issues/48) |
Also verified from an independent direction: [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
confirms TiviMate "doesn't capture anything" for notification-based playback
detection on the Shield — consistent with our R2 no-media-session finding, and a
reminder that projects tried and failed to get sessions out of TiviMate
(see §5).
## 2. androidtvremote2 capabilities we are not using (verified from [library source](https://github.com/tronikos/androidtvremote2))
Source of truth read: [`androidtv_remote.py`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py),
[`remotemessage.proto`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto),
[`TvKeys.txt`](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
| Capability | API | MVP relevance |
|---|---|---|
| Key codes + length variants | `send_key_command(key, direction)` — `SHORT`/`START_LONG`/`END_LONG`; accepts int or `"KEYCODE_..."/"POWER"` strings; any code from the 300-strong proto enum | Remote entity port (Phase 2) matches core's SHORT/START_LONG/END_LONG design |
| **Text / IME** | `send_text(text)`; also `send_key_command("text:<message>")` routes into send_text | Phase-2 IME option (default off). Known quirk: append-vs-replace inconsistency (#32) — document, don't ship cleverness |
| App launch | `send_launch_app_command(app_link_or_app_id)` — **auto-prefixes `market://launch?id=`** when the value has no URL scheme (C-LAUNCH) | `play_media(url)`/`play_media(app)` (Phase 4): bare package names work with zero extra code; deep links like `plex://` pass through unchanged |
| Push callbacks | `add_is_on_updated_callback`, `add_current_app_updated_callback`, `add_volume_info_updated_callback`, `add_is_available_updated_callback` | Exactly the manager-listener pattern Phase 1/2 needs; `current_app` push is the Phase-3 primary foreground signal |
| Volume info (read-only) | `volume_info` → `(level, max, muted)` pushed on change | Real mute state + numeric level readout in Phase 2 — **but no `volume_set`**; see C-VOL |
| Reconnect + reauth distinction | `keep_reconnecting(invalid_auth_callback=<fn>)` with internal exponential backoff 0.1 s → 30 s cap; raises `InvalidAuth` when certs are rejected (device hard reset) | Feeds the transport design; **one owner per transport** — don't build a competing watchdog for the protocol socket; `InvalidAuth` → reauth flow, not retry storm |
| Device info | `device_info` (model, vendor, package_name, app_version) | Free diagnostics data for Phase 4 (and a nice setup sanity check) |
| App-list push | **Not implemented and not in the protocol this client speaks** ([#17](https://github.com/tronikos/androidtvremote2/issues/17)). Verified against the full proto: the only app-ish messages are `RemoteAppInfo` (IME context) and `RemoteAppLinkLaunchRequest` — no app-list leafs | Confirms: options-defined app name/icon map is the honest substitute; do not promise app-list discovery |
| IME sensitivity | Constructor `enable_ime=True` — "**Needed for getting current_app**; disable for devices that show 'Use keyboard on mobile device screen'" | If we make IME configurable, document that disabling it can break the `current_app` push (which Phase 3 depends on) |
| Voice | `start_voice()` → `VoiceStream` (PCM 16-bit mono 8 kHz, 20 KB chunks), `enable_voice=True` | Post-MVP |
| `RemoteSetVolumeLevel` exists in the proto but is [sent by noone](https://github.com/tronikos/androidtvremote2/issues/176505) (see C-VOL) | — | Do not plan on it for VOLUME_SET |
## 3. Patterns worth borrowing from other HA integrations (docs verified)
All four integration docs read in full ([Roku](https://www.home-assistant.io/integrations/roku/),
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/),
[Bravia](https://www.home-assistant.io/integrations/braviatv/),
[androidtv](https://www.home-assistant.io/integrations/androidtv/)).
| Integration | Mechanism | What to borrow | Effort for us |
|---|---|---|---|
| **Roku** | `play_media(media_content_type: channel)` → tune by number (e.g. `5.1`); `select_source` accepts app name *or* app id; BrowseMedia for apps; channel tuning for Roku TV devices; `media_content_type: app` with `extra.content_id`/`extra.media_type` for content deeplinks; `remote` commands incl. `channel_up`/`channel_down`; active app + active app id sensors; `media_content_type: url` + `extra{format,name,thumbnail,artist_name}` for PlayOnRoku direct playback; obtains app list via a published XML endpoint (`/query/apps`) | *Channel tuning semantic* — exactly our `channel` port (validated). *App deep link envelope shape*: `media_content_type: app` with `extra.content_id`/`extra.media_type` is a richer pattern than raw URLs if we later want per-app content launches — record it but don't build it for MVP. *Source list built from apps* matches our `select_source` = launch app. *Channel up/down as remote commands* — bolt `channel_up`/`channel_down` onto Phase-2 remote command set (keycodes 166/167, free) | Low–medium |
| **Apple TV** | `play_media(media_content_type: url)` with `youtube://...`, `https://www.netflix.com/title/...` "Deep Links" (canonical how-to + link-discovery tips via iOS share sheets); `select_source` + BrowseMedia "Apps" folder for launching apps; `keyboard` entity (binary_sensor "keyboard focused") + three text actions (`set/append/clear_keyboard_text`) driven by keyboard-focus push; remote entity with `num_repeats`/`delay_secs`/`hold_secs` | *play_media(url) deep-link semantics* for Phase 4, direct port (mediatype `url` → `send_launch_app_command`). *"Apps folder"* validation for BrowseMedia. *Keyboard-focus push → typed-text entry* is Apple TV's answer to #32's append/replace ambiguity — a keyboard entity would need an IME-push equivalent the Shield protocol does not give us cheaply, so for MVP ship `send_text` only when IME is on (nothing fancier). *Remote entity parameters* (`num_repeats`/`delay_secs`/`hold_secs`) are the shape we already planned | Medium (only if we chase keyboard); Low for the rest |
| **Sony Bravia** | `play_media` matches channel number, **exact or partial app/channel name**, or URI string; BrowseMedia lists installed apps + TV channels; remote commands incl. `Num0..Num9` (digit-typing built into the remote platform); documented real-world limitation that TVs running Android/Google TV lose in-app playback info unless you pair with Google Cast (they recommend **Universal Media Player with two children** — exactly the problem our all-in-one design solves natively) | *Fuzzy matching on media_content_id* (number OR exact/partial name) is a small and very forgiving UX win for `channel` — accept a numeric string strictly, but also accept exact channel/app names from an options-defined map if cheap. *The Bravia doc explicitly documents the Cast+Universal-Media-Player workaround* — citable pattern that our native ladder replaces for Shield users | Low (for the simple variant) |
| **androidtv (core, ADB/Fire TV)** | `select_source` = launch app; **`!` prefix closes app** (`!com.netflix.ninja`); options editors for *apps list, exclude unknown, arbitrary ADB turn_on/turn_off commands, per-app `state_detection_rules`*; screen-capture album art option; `adb_command`/`upload`/`download` actions; `learn_sendevent` helper | *`!` close-app prefix is a tiny, delightful, zero-new-dep feature* — implement for free alongside our Phase-4 launch path; document it as an androidtv compatibility nicety. *Arbitrary turn_on/turn_off commands as options* is worth matching (we already have shell; gives users a knob without code). *`state_detection_rules`* (per-app playing/paused conditions over `media_session_state`, `audio_state`, `wake_lock_size`) is the blueprint for our last-known cache tier to become *configurable* if users need app-specific truth — out of ADB/MVP scope but record it. *Screen-capture album art* is the precedent for the screencap idea in §4 (Roku/Bravia do not need it; we do) | Low |
Canonical patterns (all four):
`select_source` = launch app with `source_list` of apps; BrowseMedia with a flat
"Apps" directory; `play_media` channel digits or named matches; per-app
icon/name map; documented `media_content_type` vocabulary (`channel`, `app`, `url`,
`music`, `video`) — we should mirror this vocabulary exactly in Phase 2/4.
## 4. adb_shell capabilities we are not using (verified from [adb_shell source](https://github.com/JeffLIrion/adb_shell))
Source of truth read: [`adb_shell/adb_device.py`](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) (the full file) and [README.rst](https://raw.githubusercontent.com/JeffLIrion/adb_shell/master/README.rst).
| Capability | API | MVP relevance |
|---|---|---|
| **Streaming shell** | `AdbDevice.streaming_shell(command, decode=True)` — a *generator that yields lines as they arrive, until the connection is closed* | ⚠️ Plan correction. `MVP_PLAN.md` §2 currently says "`adb_shell`'s shell() is request→full-response; there is no incremental/streaming read API". **That is no longer accurate on master** — `streaming_shell` exists as a documented generator on `AdbDevice`. Two caveats before acting: (a) verify it is present in the resolved version we pin (repo last pushed 2024‑10; check release ≥ ours covers it — the version is old enough that the feature set may differ slightly); (b) verify the *async* device class exposing it (the source read was the sync `AdbDevice`; the async subclass typically mirrors). If both check out, MVP_PLAN's "Phase-4 optional: logcat streaming behind an option" becomes **much cheaper than hacking adb_shell internals** — and the deferred logcat snapshot approach gains a real path. Rank as the single highest-value adb_shell finding |
| **FileSync** | `push()`, `pull(device_path, local_path, ...)`, `list(device_path)` — full FileSync protocol | Enables clean `pull`-based `screencap -p /local && pull` work flows (crisper than `shell screencap -p` stdout parsing) for album art, debugging, or future thumbnails; zero new deps |
| Lazy shell decode | `decode=False` on shell | Micro-win for binary/inspection commands |
| USB | `AdbDeviceUsb` (experimental, `[usb]` extra) | Not for MVP (cradle-cable setup); note for future reference only |
| Long-lived TCP connect | `AdbDeviceTcp(host, port, default_transport_timeout_s=...)` + `connect(rsa_keys=[signer], auth_timeout_s=...)` with per-call timeouts | What Phase 1 already relies on; the per-transaction timeout fields (`transport_timeout_s`/`read_timeout_s`/`timeout_s`) are worth being explicit about in the manager's health-check design rather than default-None everywhere |
## 5. TiviMate specifics
**What TiviMate gives us: essentially nothing at the device level.**
- Package `ar.tvplayer.tv`; no media session (R2, verified twice — before/after a
zap produced byte-identical output) and appears only in the "Audio playback"
section of `dumpsys media_session`.
- [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4):
independent confirmation that TiviMate produces nothing captureable by
notification/session bridges on the Shield.
- [Channel-switch automation thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344):
the community's only working channel-select recipe is the digit-keyevent path we
already planned (plus a ~7 s delay for app cold start).
- **No deep link published by users** in the deep-linking guide or elsewhere for
`ar.tvplayer.tv` — treat as "no shortcut"; if ever one surfaces, `am start` with
extras via ADB is the Phase-4 "apps the protocol can't launch" seam.
- **Channel list export:** TiviMate does not expose a device-side API for the
channel list the community can use — the ecosystem gets channels from the
upstream M3U/XMLTV sources that TiviMate loads, not from the app. Community
projects around TiviMate (playlists, EPG ([EPGTalk](https://github.com/acidjesuz/EPGTalk)), playlist auto-updaters) take
their data from those links. **MVP implication:** if we ever want "channel
N = <name>" metadata, the data side belongs to the user's playlist/EPG pipeline
(e.g. options field to store a channel list file), *not* to the integration
scraping the TV.
- **Channel/zap-change detection while watching:** no project has reliable on-device
telemetry (the R2 capture shows the dump is zap-invariant). Realistic best
signals remain the ones we have chosen: A "Audio playback" presence (identity)
and `last_channel` cache (what *we* last asked for). The Phase-1 optional
logcat-on-a-device process channel may eventually reveal app-internal logs, but
no public, stable, IPTV-app-level pattern exists to borrow.
**Conclusion:** our current design (digits via keys, foreground fallback + audio-playback
hint + last-known cache) is at parity with what the community does; the only
additions worth recording are the ~7 s app-cold-start note in automation docs and the
"channel list data belongs outside the integration" decision.
## 6. Ranked ideas
Ranking by: (a) fits locked MVP decisions, (b) cost, (c) user-verifiable payoff.
"Phase" is where it should land in the current plan.
| Rank | Idea | MVP fit | Effort | Phase |
|---|---|---|---|---|
| 1 | **`channel_up` / `channel_down` on the remote+media_player command sets** — free with the protocol's `KEYCODE_CHANNEL_UP`(166)/`KEYCODE_CHANNEL_DOWN`(167); Roku/Bravia precedent for making tuning feel native; zero new logic since we already send keycodes for digits | High | Trivial (hours) | 2 (fall-out) |
| 2 | **`!` app-close prefix on `select_source`/`play_media(app)`** — androidtv precedent (`!com.netflix.ninja`); one-stop fix for "app won't die" automation scripts; zero new deps or surfaces | High | Small (half day incl. tests) | 4 |
| 3 | **`play_media(media_content_type: url)` deep links via `send_launch_app_command`** with a cloud-less data-driven *deeplink map option* (validated against the community-wide guide; the library accepts either raw deep link or bare package id) — plus the guide's `adb shell cmd package dump` trick documented for contributors to discover links per app | High | Small–medium (~1 day incl. options editor) | 4 |
| 4 | **BrowseMedia "Apps" directory** from running_apps + options-defined app map (androidtv_remote/Apple TV/Bravia precedent) — the single most-requested QoL pattern in this space and the reason users hit external cards ([#117126](https://github.com/home-assistant/core/pull/117126), [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48)) | High | Medium (~1–2 days incl. icons + tests) | 4 |
| 5 | **Volume readout hardening in Phase 2** — `volume_info` push sourced real `volume_level` and mute, with `media_player.volume_set` deliberately unsupported (C-VOL correction, already adopted); if we hear repeated user demand later, offer an ADB-step-loop helper service documented as such (not fake features) | Medium (already handled) | done (plan note only) | 2 |
| 6 | **adb_shell streaming_shell probe** — spike: confirm `streaming_shell` exists at our pinned adb-shell version and on the async class; if yes, MVP_PLAN's deferred logcat-on-ADB idea is feasible via a documented generator rather than internal hacks; if no, keeps the snapshot-only plan honest. Cheap spike, high leverage on a currently-"impossible" feature | Medium | Small (half-day spike) | 4 (optional path) |
| 7 | **Bravia-style tolerant `media_content_id` matching** for `channel` (accept `5.1`-style floats and exact-name matches from the app/channel map) — small UX win, safe with a strict numeric path retained | Medium | Small | 3/4 |
| 8 | **Screen-capture album art (adb_shell `screencap`/`pull`)** — androidtv precedent supplies the pattern; useful only for our PlayOnRoku-missing, Jellyfin-cached apps; keep it as a documented option like androidtv does, since it costs extra ADB shell round trips when playing and is privacy-scope-sensitive | Medium | Medium (~1 day + options slosh) | Post-MVP |
| 9 | **IME text entry surfaced as a remote/service integration** — possible now that the library has `send_text`/`text:` syntax, but per #32 behavior is inconsistent; Apple TV solves this with a keyboard-focus push that the Shield protocol doesn't give us cheaply; keep IME an options toggle, not an entity | Low for MVP | Medium | 4 (option only) |
| 10 | **`state_detection_rules`-style per-app configurable state ladder** (androidtv pattern) — only if/when users ask for app-specific truth beyond ADB sessions (e.g. IPTV VOD pages); nice future seam; don't build pre-emptively | Low for MVP | Medium–large | Post-MVP |
| 11 | **Voice (`start_voice`)** — available in the library, `enable_voice=True` and device-feature-gated; not in our MVP scope, recorded so it's not re-researched | Low | Medium | Post-MVP |
Key references for the rankings (primary, not forum threads only):
[core#176505](https://github.com/home-assistant/core/issues/176505) ·
[#117126](https://github.com/home-assistant/core/pull/117126) ·
[#17](https://github.com/tronikos/androidtvremote2/issues/17) ·
[#32](https://github.com/tronikos/androidtvremote2/issues/32) ·
[#28](https://github.com/tronikos/androidtvremote2/issues/28) ·
[androidtvremote2 source](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py) ·
[proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto) ·
[adb_shell device](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) ·
[Roku](https://www.home-assistant.io/integrations/roku/) ·
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/) ·
[Bravia](https://www.home-assistant.io/integrations/braviatv/) ·
[androidtv](https://www.home-assistant.io/integrations/androidtv/) ·
[deep linking guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) ·
[community TiviMate thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) ·
[Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48) ·
[MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
---
## 7. Explicit MVP-plan corrections proposed (for Phase-3/4 lands)
1. **C-VOL (already adopted by captain).** `volume_set` is not protocol-native — the
supported features in Phase 2 stay `VOLUME_STEP|MUTE`, with real numeric
`volume_level` and `is_volume_muted` readouts from the protocol's `volume_info`
push. Absolute-volume demand, if it ever recurs, is addressed honestly via an
ADB `input keyevent KEYCODE_VOLUME_*` helper script, documented as such, not as
a fake supported feature.
2. **C-STREAM (new).** MVP_PLAN §2's statement that adb_shell offers no
incremental/streaming read should be softened: `streaming_shell` exists
upstream (at least on master) and might make the deferred Phase-4 logcat idea
a one-day spike instead of "needs new dependency". Verify our pinned version
carries it and whether the async device class exposes it, then either upgrade
to a "cheap Phase-4 item" or keep the deferred note with accurate wording.
3. **C-LAUNCH (new, informational).** `send_launch_app_command` already accepts a bare
package id by auto-prefixing `market://launch?id=`; Phase 4 needs no extra
branch for "app" launches, only for deep links (where the value already has a
scheme) and `!`-close via ADB (only when the protocol path is not enough).
4. **C-TIVI (new).** TiviMate has no usable on-device channel metadata or export
surface; the honest MVP display is "on, in TiviMate" + `last_channel` cache
from our own play_media calls. Channel-name metadata is a user-supplied
playlist/EPG concern, out of scope (and out of ADB's reach).
+27
View File
@@ -394,3 +394,30 @@ media_player:
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once 9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
Phase 3 lands (documented in README). Phase 3 lands (documented in README).
10. Netflix stale-session fixture pins "inactive no-state sessions ignored". 10. Netflix stale-session fixture pins "inactive no-state sessions ignored".
---
## Appendix — mediasession2mqtt retirement + universal-player glue (Phase 3)
The fork retires the surrounding glue once the state ladder (README, Phase 3)
covers what the external pieces provided:
| External piece | What it provided | Now native (file) |
|---|---|---|
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
The migration path: point the automations/scripts at the integration's DAG
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
needed for the MVP feature set; the appendix exists so retirement can be
staged per household.
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
publishes no MediaSession on Shield Android 11. Its visibility comes from the
foreground tier (tier 3.5) with the audio-playback hint and the
last-known-state cache — never from a session, never resurrected past the
state TTL.
+4 -7
View File
@@ -14,14 +14,11 @@ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
"ruff>=0.4", "ruff>=0.4",
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/ "androidtvremote2>=0.2.0",
# for HACS installs; used editable from a sibling checkout for development.
# Planned migration to a proper PyPI package.
"ha-adb-common",
] ]
# The shared ADB helpers are vendored at
[tool.uv.sources] # custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
ha-adb-common = { path = "../ha-adb-common", editable = true } # this fork, so no extra dev dependency (and no unavailable sibling checkout).
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
+52
View File
@@ -0,0 +1,52 @@
"""Test bootstrap.
Puts the vendored ha_adb_common package on sys.path so helpers can be imported
directly (their code is stdlib-only) without installing Home Assistant or
executing the integration's __init__ module.
The integration-package modules that stay Home-Assistant-free at import time
(currently connection.py) are also made importable — via a synthetic package
("nsh") that wraps custom_components/nvidia_shield WITHOUT executing its
__init__.py (that one imports Home Assistant). Only the stdlib-only modules
are loaded into it explicitly; their relative imports resolve normally.
"""
import importlib.util
import sys
import types
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "custom_components" / "nvidia_shield"))
_SHIELD_DIR = REPO_ROOT / "custom_components" / "nvidia_shield"
_PKG = "nsh"
def _load_module(name: str, path: Path, search_paths: list[str] | None = None):
"""Import a source file under ``name`` without touching real packages."""
spec = importlib.util.spec_from_file_location(
name, path, submodule_search_locations=search_paths
)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
# Synthetic integration package — no __init__.py execution (HA-free).
_root = types.ModuleType(_PKG)
_root.__path__ = [str(_SHIELD_DIR)]
sys.modules[_PKG] = _root
# Real subpackage __init__ (stdlib-only) so `from .ha_adb_common import ...`
# works through the synthetic package.
_load_module(
f"{_PKG}.ha_adb_common",
_SHIELD_DIR / "ha_adb_common" / "__init__.py",
[str(_SHIELD_DIR / "ha_adb_common")],
)
# Stdlib-only integration modules.
for _mod in ("const", "adb_session", "connection"):
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
+235
View File
@@ -0,0 +1,235 @@
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
enum values the integration actually uses, and the event helpers. Tests load
their entity module through the synthetic ``nsh`` package AFTER calling
:func:`install` (and the per-file fixture pops everything at teardown).
"""
import asyncio
import enum
import sys
import types
ENTITIES_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.const",
"homeassistant.components",
"homeassistant.components.media_player",
"homeassistant.components.remote",
"homeassistant.components.button",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.selector",
"homeassistant.helpers.event",
"homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform",
"nsh.helpers",
"nsh.power",
"nsh.jellyfin",
"nsh.media_player",
"nsh.diagnostics",
"nsh.remote",
)
class MediaPlayerState(enum.StrEnum):
OFF = "off"
ON = "on"
IDLE = "idle"
PLAYING = "playing"
PAUSED = "paused"
class MediaPlayerEntityFeature(enum.IntFlag):
PLAY = 1
PAUSE = 2
STOP = 4
VOLUME_MUTE = 8
VOLUME_STEP = 16
VOLUME_SET = 32
BROWSE_MEDIA = 4096
TURN_ON = 64
TURN_OFF = 128
PLAY_MEDIA = 256
NEXT_TRACK = 512
PREVIOUS_TRACK = 1024
class MediaClass:
APP = "app"
DIRECTORY = "directory"
class BrowseMediaSource:
"""HA BrowseMedia shape used by the app directory tests."""
def __init__(
self,
*,
title,
media_class,
media_content_type,
media_content_id,
can_play,
can_expand,
children=None,
thumbnail=None,
):
self.title = title
self.media_class = media_class
self.media_content_type = media_content_type
self.media_content_id = media_content_id
self.can_play = can_play
self.can_expand = can_expand
self.children = children or []
self.thumbnail = thumbnail
class MediaEntityManager:
"""trivial stand-in for the HA platform internals we don't exercise."""
class MediaPlayerEntity:
"""Base entity: attributes only; state writing is a no-op."""
def async_write_ha_state(self) -> None:
return None
class RemoteEntity:
def async_write_ha_state(self) -> None:
return None
def install() -> None:
"""Register the stub modules (idempotent)."""
if "homeassistant.components.media_player" in sys.modules:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
config_entries.ConfigEntry = type("ConfigEntry", (), {})
_config_flow_base = type(
"ConfigFlow",
(),
{
"__init_subclass__": classmethod(
lambda cls, domain=None, **kwargs: None
),
},
)
config_entries.ConfigFlow = _config_flow_base
config_entries.OptionsFlow = type("OptionsFlow", (), {})
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
core.CALLBACK_TYPE = object
core.Event = object
sys.modules["homeassistant.core"] = core
ha_const = types.ModuleType("homeassistant.const")
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
ha_const.STATE_PLAYING = "playing"
ha_const.STATE_PAUSED = "paused"
ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player",
SENSOR="sensor",
REMOTE="remote",
BUTTON="button",
)
sys.modules["homeassistant.const"] = ha_const
components = types.ModuleType("homeassistant.components")
components.__path__ = []
sys.modules["homeassistant.components"] = components
mp = types.ModuleType("homeassistant.components.media_player")
mp.MediaPlayerEntity = MediaPlayerEntity
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor
mp.BrowseMediaSource = BrowseMediaSource
mp.MediaClass = MediaClass
mp.MediaPlayerState = MediaPlayerState
sys.modules["homeassistant.components.media_player"] = mp
remote = types.ModuleType("homeassistant.components.remote")
remote.RemoteEntity = RemoteEntity
sys.modules["homeassistant.components.remote"] = remote
button = types.ModuleType("homeassistant.components.button")
button.ButtonEntity = type("ButtonEntity", (), {})
sys.modules["homeassistant.components.button"] = button
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
event = types.ModuleType("homeassistant.helpers.event")
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
helpers.event = event
helpers.selector = types.SimpleNamespace(
EntitySelector=lambda *a, **k: None,
EntitySelectorConfig=lambda *a, **k: "",
SelectSelector=lambda *a, **k: None,
SelectSelectorConfig=lambda **k: "",
SelectSelectorMode=types.SimpleNamespace(DROPDOWN="dropdown"),
)
entity = types.ModuleType("homeassistant.helpers.entity")
entity.DeviceInfo = dict
helpers.entity = entity
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
entity_platform.AddEntitiesCallback = object
helpers.entity_platform = entity_platform
sys.modules["homeassistant.helpers.event"] = event
sys.modules["homeassistant.helpers.entity"] = entity
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
sys.modules["homeassistant.helpers"] = helpers
def load_entity_modules() -> dict:
"""Load the integration's HA-free-ish entity modules under nsh."""
install()
from conftest import _SHIELD_DIR, _load_module
for name in ("helpers", "power", "jellyfin", "media_player", "remote", "diagnostics"):
if f"nsh.{name}" not in sys.modules:
_load_module(f"nsh.{name}", _SHIELD_DIR / f"{name}.py")
return {
name: sys.modules[f"nsh.{name}"]
for name in ("power", "jellyfin", "media_player", "remote", "diagnostics")
}
class FakeHass:
"""Sized to entity unit tests: background tasks + no-op call_later."""
def async_create_task(self, coro):
return asyncio.create_task(coro)
def async_call_later(self, delay, callback_fn):
return lambda: None
+5
View File
@@ -0,0 +1,5 @@
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
state=RESUMED stopped=false delayedResume=false finishing=false
Resumed activities in task display areas (from top to bottom):
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
+12
View File
@@ -0,0 +1,12 @@
SYNTHETIC fixture — not a real device capture. The real Shield `dumpsys
activity recents` capture (R6) is still outstanding; this file pins the
parsing contract of AdbClientBase.poll_running_apps in the meantime, covering
both the A=uid:package and the I=package/activity fallback patterns.
Represents the post-grep output of:
dumpsys activity recents 2>/dev/null | grep -E 'Recent #|realActivity|visible='
Note: line order = recency order (most recently used first).
* Recent #0: Task{2c9f81f #817 type=standard A=10185:ar.tvplayer.tv I=ar.tvplayer.tv/.ui.MainActivity ... visible=true numActivities=1 ...}
* Recent #1: Task{3dae20b #802 type=standard A=10106:com.github.damontecres.wholphin ... visible=false numActivities=1 ...}
* Recent #2: Task{4b7ce01 #788 type=standard I=com.example.launcherapp/.ui.HomeActivity ... visible=true ...}
* Recent #3: Task{5c8dd12 #770 type=standard A=1000:com.android.systemui ... visible=true ...}
* Recent #4: Task{6d9ef23 #750 type=unknown A=10003:.HiddenInternalTask ... visible=true ...}
+2
View File
@@ -0,0 +1,2 @@
mWakefulness=Asleep
mWakefulnessChanging=false
+2
View File
@@ -0,0 +1,2 @@
mWakefulness=Awake
mWakefulnessChanging=false
+33
View File
@@ -0,0 +1,33 @@
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
Global priority session is null
User Records:
Record for full_user=0
Volume key long-press listener: null
Volume key long-press listener package:
Media key listener: null
Media key listener package:
OnMediaKeyEventDispatchedListener: added 0 listener(s)
OnMediaKeyEventSessionChangedListener: added 1 listener(s)
from com.android.bluetooth
Last MediaButtonReceiver: null
Media button session is null
Sessions Stack - have 1 sessions:
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
ownerPid=5833, ownerUid=10080, userId=0
package=com.netflix.ninja
launchIntent=null
mediaButtonReceiver=null
active=false
flags=3
rating type=0
controllers: 0
state=null
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: null
queueTitle=null, size=0
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
+23
View File
@@ -0,0 +1,23 @@
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
User Records:
Record for full_user=0
Media button session is null
Sessions Stack - have 1 sessions:
Wholphin media session com.github.damontecres.wholphin/Wholphin media session (userId=0)
ownerPid=6117, ownerUid=10117, userId=0
package=com.github.damontecres.wholphin
launchIntent=null
mediaButtonReceiver=null
active=true
flags=3
rating type=0
controllers: 1
state=PlaybackState {state=3, position=17237, buffered position=70070, speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item id=0, error=null}
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: size=11, description=Twirlywoos, null, null
queueTitle=null, size=1
Audio playback (lastly played comes first)
uid=10117 packages=com.github.damontecres.wholphin
+208
View File
@@ -0,0 +1,208 @@
"""Tests for AdbClientBase additions: exact wakefulness parse (R5),
health_check(), and optional media-session truncation (plan blocker B4).
The client runs against a FakeDevice recording shell commands — no real ADB
socket or adb_shell import is needed.
"""
from pathlib import Path
from ha_adb_common.adb_client import AdbClientBase, parse_wakefulness
FIXTURES = Path(__file__).parent / "fixtures"
CMD_WAKEFULNESS = "dumpsys power 2>/dev/null | grep mWakefulness"
CMD_MEDIA_DEFAULT = "dumpsys media_session 2>/dev/null | head -200"
CMD_MEDIA_FULL = "dumpsys media_session 2>/dev/null"
CMD_ECHO_OK = "echo ok"
class FakeDevice:
"""Records shell commands and returns canned responses per command."""
def __init__(self, responses=None):
self._responses = dict(responses or {})
self.commands: list[str] = []
async def shell(self, cmd: str) -> str | None:
self.commands.append(cmd)
response = self._responses.get(cmd)
if isinstance(response, Exception):
raise response
return response
def _client_response(cmd: str, response: str | None) -> AdbClientBase:
"""A client whose (fake) device is already connected and answers cmd."""
client = AdbClientBase("shield.local", 5555, "/dev/null/adbkey")
client._device = FakeDevice({cmd: response})
client._connected = True # skip connect(): no adb_shell in the test env
return client
class TestParseWakefulness:
"""Exact-value mWakefulness= parse; mWakefulnessChanging excluded."""
def test_awake_fixture(self):
text = (FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text()
assert parse_wakefulness(text) is True
def test_asleep_fixture(self):
text = (FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text()
assert parse_wakefulness(text) is False
def test_known_asleep_values(self):
assert parse_wakefulness("mWakefulness=Asleep") is False
assert parse_wakefulness("mWakefulness=Dozing") is False
assert parse_wakefulness("mWakefulness=Dozing-suspend") is False
def test_unknown_value_is_unknown_not_awake(self):
"""The old substring heuristic read any future value containing no
'Awake' as asleep; unrecognized values must be None (captain's note)."""
assert parse_wakefulness("mWakefulness=Dozing-ext") is None
assert parse_wakefulness("mWakefulness=123") is None
def test_changing_and_setting_lines_are_ignored(self):
assert (
parse_wakefulness(" mWakefulnessChanging=false") is None
)
assert parse_wakefulness(" mWakefulnessSetting=2") is None
def test_assignment_wins_over_changing_line(self):
text = " mWakefulnessChanging=false\nmWakefulness=Awake\n"
assert parse_wakefulness(text) is True
def test_empty_input(self):
assert parse_wakefulness("") is None
assert parse_wakefulness(None) is None
assert parse_wakefulness("no match here") is None
class TestIsAwake:
async def test_awake(self):
client = _client_response(
CMD_WAKEFULNESS,
(FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text(),
)
assert await client.is_awake() is True
assert client._device.commands == [CMD_WAKEFULNESS]
async def test_asleep(self):
client = _client_response(
CMD_WAKEFULNESS,
(FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text(),
)
assert await client.is_awake() is False
async def test_unknown_reports_none(self):
"""A garbage/empty grep result parses to None, not False."""
client = _client_response(CMD_WAKEFULNESS, "")
assert await client.is_awake() is None
class TestHealthCheck:
async def test_echo_ok_round_trip_is_healthy(self):
client = _client_response(CMD_ECHO_OK, "ok\n")
assert await client.health_check() is True
async def test_failed_shell_is_unhealthy(self):
client = _client_response(CMD_ECHO_OK, Exception("socket closed"))
assert await client.health_check() is False
assert client.connected is False # shell() marked the socket broken
async def test_wrong_body_is_unhealthy(self):
client = _client_response(CMD_ECHO_OK, "something else\n")
assert await client.health_check() is False
class TestPollMediaSessionsTruncation:
async def test_default_truncates_at_200(self):
client = _client_response(CMD_MEDIA_DEFAULT, "")
assert await client.poll_media_sessions() is None
assert client._device.commands == [CMD_MEDIA_DEFAULT]
async def test_explicit_head_lines(self):
client = _client_response("dumpsys media_session 2>/dev/null | head -80", "")
await client.poll_media_sessions(head_lines=80)
assert client._device.commands == [
"dumpsys media_session 2>/dev/null | head -80"
]
async def test_none_head_lines_fetches_full_dump(self):
"""None = no truncation — the manager later passes None (B4)."""
wholphin = (FIXTURES / "media_session_wholphin.txt").read_text()
client = _client_response(CMD_MEDIA_FULL, wholphin)
info = await client.poll_media_sessions(head_lines=None)
assert client._device.commands == [CMD_MEDIA_FULL]
assert info is not None
assert info.package == "com.github.damontecres.wholphin"
assert info.position == 17237
assert client.all_sessions[0].playing is True
async def test_failed_shell_returns_none_without_reparsing(self):
client = _client_response(CMD_MEDIA_DEFAULT, Exception("dead"))
assert await client.poll_media_sessions() is None
assert client.last_info is None
class TestPollRunningApps:
"""Recent-task parsing against the SYNTHETIC recents fixture.
The real Shield `dumpsys activity recents` capture (R6) is still
outstanding; tests/fixtures/activity_recents_synthetic.txt pins the
parsing contract in the meantime.
"""
async def test_parses_visible_user_apps_in_recency_order(self):
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
assert [a["package"] for a in apps] == [
"ar.tvplayer.tv",
"com.example.launcherapp",
]
async def test_i_pattern_fallback_used_without_a_uid_assignment(self):
"""`I=com.example.launcherapp/.ui.HomeActivity` is the only package
fragment on its line and must be picked up by the fallback regex."""
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
assert "com.example.launcherapp" in {a["package"] for a in apps}
async def test_skips_invisible_tasks_system_packages_and_dot_names(self):
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
packages = {a["package"] for a in apps}
assert "com.github.damontecres.wholphin" not in packages # visible=false
assert "com.android.systemui" not in packages # excluded system UI
assert ".HiddenInternalTask" not in packages # dot-named internals
async def test_empty_result_returns_empty_list(self):
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
None,
)
assert await client.poll_running_apps() == []
async def test_failed_shell_returns_empty_list(self):
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
Exception("offline"),
)
assert await client.poll_running_apps() == []
+965
View File
@@ -0,0 +1,965 @@
"""t10 tests: command routing, play_media channel port, remote entity, volume.
Concrete fakes expose the ordering precisely: protocol → ADB keyevent →
legacy external entity, plus the §7 channel digit sequence (protocol digits,
~0.1 s apart, no ENTER, new sequence cancels in-flight). A scripted media
layer marks which tier served each command.
"""
import asyncio
import sys
import entity_stublib
import pytest
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
from nsh.connection import ShieldConnectionManager
from nsh.const import TIVIMATE_PACKAGE
@pytest.fixture(autouse=True)
def scoped_entity_stubs():
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in ENTITIES_STUB_NAMES}
install()
yield load_entity_modules()
for name in ENTITIES_STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
class FakeProtocolClient:
"""Records key/launch calls; optional scripted failures."""
def __init__(self):
self.key_calls: list[tuple[str, str]] = []
self.launch_calls: list[str] = []
self.fail_keys: set[str] = set()
self.fail_all = False
def send_key_command(self, key, direction="SHORT"):
self.key_calls.append((key, direction))
if self.fail_all or key.upper() in self.fail_keys:
raise ConnectionError("protocol transport down")
def send_launch_app_command(self, app_link_or_app_id: str) -> None:
self.launch_calls.append(app_link_or_app_id)
if self.fail_all:
raise ConnectionError("transport down")
class FakeAdbPoller:
"""Minimal ADB surface for keyevent and digit fallback paths."""
def __init__(self):
self.media_calls: list[str] = []
self.digits: list[str] = []
self.enter_calls: list[bool] = []
self.results: dict[str, bool] = {}
self.digit_result = True
@property
def connected(self):
return True
async def send_media_command(self, command: str) -> bool:
self.media_calls.append(command)
return self.results.get(command, True)
async def send_digit_keyevent(self, digit: str) -> bool:
self.digits.append(digit)
return self.digit_result
async def send_enter_keyevent(self) -> bool:
self.enter_calls.append(True)
return True
async def bring_to_foreground(self, package: str) -> bool:
return True
def make_manager(store=None, poller=None, protocol=None):
return ShieldConnectionManager(
hass=None,
entry=None,
poller=poller or FakeAdbPoller(),
protocol=protocol or FakeProtocolClient(),
store=store,
)
@pytest.fixture(name="modules")
def load_modules_fixture():
return load_entity_modules()
def make_player(modules, manager=None, jellyfin_entity=None, options=None):
"""A pre-initialised media_player entity bound to the tested manager."""
from types import SimpleNamespace
mp_module = modules["media_player"]
if manager is None:
manager = make_manager()
entry = SimpleNamespace(options={}, data={}, title="mdarcy")
manager.entry = entry
def _set_option(key, value):
entry.options[key] = value
entry.set_option = _set_option
player = mp_module.NvidiaShieldMediaPlayer.__new__(mp_module.NvidiaShieldMediaPlayer)
player.hass = None
player._entry = entry
player._manager = manager
player._intent = None
player._last_channel = None
player.hass = entity_stublib.FakeHass()
player._entity_tasks = set()
player._channel_task = None
player.async_write_ha_state = lambda: None
player._read = lambda eid: None
served = []
async def _cmd(service, **kwargs):
served.append(("legacy", service))
return None
player._cmd = _cmd
captured_intents = []
def _set_intent(service, target):
intent = SimpleNamespace(
target_state=getattr(target, "value", target),
command=service,
created_at=0.0,
prior_state=None,
retry_count=0,
via_adb=False,
via_protocol=False,
adb_command=None,
protocol_key=None,
)
def _unsub():
return None
player._intent = intent
captured_intents.append(intent)
return None
player._set_intent = _set_intent
return player, served, captured_intents
class TestMediaCommandRouting:
async def test_protocol_serves_media_command(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
player._intent = None
player.async_write_ha_state = lambda: None
await player._media_cmd("media_play", "play", "playing")
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
assert served == []
async def test_intent_records_protocol_path_for_retries(self, modules):
player, _served, _intents = make_player(modules)
player._set_intent("media_play", type("S", (), {"value": "playing"})())
player.async_write_ha_state = lambda: None
await player._media_cmd("media_play", "play", "playing")
# retry metadata stamped: the protocol path is recorded on the intent
assert player._intent is not None
assert player._intent.via_protocol is True
assert player._intent.protocol_key == "KEYCODE_MEDIA_PLAY"
class TestChannelDigits:
async def test_digit_sequence_digit_part_delivery(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
await player._channel_entry_sequence(["4", "0", "9", "6"])
# protocol digits: KEYCODE_{digit} SHORT each
assert protocol.key_calls == [
("KEYCODE_4", "SHORT"),
("KEYCODE_0", "SHORT"),
("KEYCODE_9", "SHORT"),
("KEYCODE_6", "SHORT"),
]
assert served == [] # never fell through to legacy
async def test_new_channel_cancels_inflight_sequence(self, modules):
player, _served, _intents = make_player(modules)
protocol = player._manager._protocol
# start a long sequence (real path), then a new play_media replaces it
first_task = player._track_task(player._channel_entry_sequence(["1", "2", "3"]))
player._channel_task = first_task # as async_play_media tracks it
await player.async_play_media("channel", "98")
await asyncio.sleep(0.02)
assert first_task.cancelled()
assert "KEYCODE_2" not in [k for k, _d in protocol.key_calls]
player._cancel_inflight_channel() # test cleanup
async def test_fallback_to_adb_digits_when_protocol_down(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
protocol.fail_all = True
await player._channel_entry_sequence(["5"])
assert protocol.key_calls == [("KEYCODE_5", "SHORT")]
assert player._manager._poller.digits == ["5"] # ADB digit keyevent served
assert served == []
async def test_optional_enter_falls_back_to_adb_keyevent(self, modules):
player, served, _i = make_player(modules)
protocol = player._manager._protocol
poller = player._manager._poller
protocol.fail_keys.add("KEYCODE_ENTER")
protocol.key_calls.clear()
player._entry.options["channel_send_enter"] = True # §7 optional ENTER on
await player._channel_entry_sequence(["4", "1"]) # digits fine, ENTER fails
# digits served by protocol; ENTER retried over ADB keyevent 66
assert ("KEYCODE_4", "SHORT") in protocol.key_calls
assert poller.digits == [] # digits were fine over the protocol
assert poller.enter_calls == [True]
assert served == []
async def test_legacy_fallback_when_both_transports_down(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
protocol.fail_all = True
player._manager._poller.digit_result = False
await player._channel_entry_sequence(["7"])
assert served == [("legacy", "play_media")] # transitional path used
class TestAppAndUrls:
async def test_app_launch_calls_protocol(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
await player.async_play_media("app", "com.plex.hdx")
assert protocol.launch_calls == ["com.plex.hdx"] # C-LAUNCH auto-prefix
assert served == []
async def test_url_launch_passes_through_unchanged(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
await player.async_play_media("url", "plex://open")
assert protocol.launch_calls == ["plex://open"]
async def test_unknown_media_type_no_send(self, modules):
player, served, _intents = make_player(modules)
protocol = player._manager._protocol
await player.async_play_media("spotify_uri", "spotify:track:x")
assert protocol.launch_calls == []
assert protocol.key_calls == []
assert served == []
class TestRemoteEntity:
async def test_split_command_prefixes(self, modules):
module = modules["remote"]
assert module._split_command("SHORT:home") == ("home", "SHORT")
assert module._split_command("START_LONG:home") == ("home", "START_LONG")
assert module._split_command("END_LONG:home") == ("home", "END_LONG")
assert module._split_command("home") == ("home", "SHORT")
async def test_key_code_name_mapping(self, modules):
module = modules["remote"]
assert module._key_code_name("home") == "KEYCODE_HOME"
assert module._key_code_name("KEYCODE_HOME") == "KEYCODE_HOME"
assert module._key_code_name("KEYCODE_HDMI_1") == "KEYCODE_HDMI_1"
assert module._key_code_name("play") == "KEYCODE_MEDIA_PLAY"
async def test_send_command_hits_protocol(self, modules):
entity = _remote_entity(modules)
protocol = entity._manager_of()._protocol
await entity.async_send_command(["HOME"])
assert protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
async def test_send_command_honors_hold_secs(self, modules):
entity = _remote_entity(modules)
protocol = entity._manager_of()._protocol
await entity.async_send_command(["home"], hold_secs=0.05)
assert protocol.key_calls == [
("KEYCODE_HOME", "START_LONG"),
("KEYCODE_HOME", "END_LONG"),
]
async def test_send_command_honors_num_repeats(self, modules):
entity = _remote_entity(modules)
protocol = entity._manager_of()._protocol
await entity.async_send_command(["home"], num_repeats=3)
assert len(protocol.key_calls) == 3
async def test_turn_on_off_send_power_key_respecting_is_on(self, modules):
entity = _remote_entity(modules)
manager = entity._manager_of()
protocol = manager._protocol
# push "already off" — turn_on should send POWER, turn_off should not
manager._on_is_on(False)
await entity.async_turn_on()
assert protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
protocol.key_calls.clear()
await entity.async_turn_off()
assert protocol.key_calls == []
# push "on" — turn_off should send POWER, turn_on should not
manager._on_is_on(True)
protocol.key_calls.clear()
await entity.async_turn_off()
assert protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
protocol.key_calls.clear()
await entity.async_turn_on()
assert protocol.key_calls == []
class TestAppFallback:
async def test_app_id_prefers_the_androidtv_entity(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")
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
async def test_app_unavailable_entity_falls_back(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.google.android.youtube.tv")
await asyncio.sleep(0.05)
assert player.app_id == "com.google.android.youtube.tv"
def _ladder_player(modules, foreground=None, audio=None):
"""Standalone player with a foreground (and optional audio hint) set."""
player, _s, _i = make_player(modules)
manager = player._manager
manager.store.set("awake", True)
if foreground:
manager.store.set("foreground", foreground)
if audio:
manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None
return player, manager
class TestForegroundLadderTier:
"""P3-3a: the ladder branch between the media-session tier and ON."""
def _player(self, modules, sessions=None, foreground=None, audio=None):
player, _s, _i = make_player(modules)
manager = player._manager
manager.store.set("awake", True)
if sessions:
manager.store.set("sessions", sessions)
if foreground:
manager.store.set("foreground", foreground)
if audio:
manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None
return player, manager
async def test_session_app_serves_playing_via_session_tier(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
sessions = [
MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")
]
player, manager = self._player(
modules, sessions=sessions, foreground="com.netflix.ninja"
)
assert player.state.value == "playing" # session tier, not the fg tier
np = player._get_adb_now_playing()
assert np["source"] == "media_session"
assert np["title"] == "Movie"
async def test_no_session_app_with_audio_hint(self, modules):
player, _m = self._player(
modules,
foreground="ar.tvplayer.tv",
audio=["ar.tvplayer.tv"],
)
# TiviMate publishes no session (R2) — fg tier keeps the device ON
assert player.state.value == "on"
np = player._get_app_now_playing()
assert np == {
"state": "idle",
"title": "Media recently active",
"source": "app",
}
async def test_no_session_app_without_hint_returns_none(self, modules):
player, _m = self._player(modules, foreground="com.spocky.projengmenu")
assert player.state.value == "on" # launcher foreground → still ON
assert player._get_app_now_playing() is None
async def test_last_known_title_beats_audio_hint(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, manager = self._player(
modules, foreground="ar.tvplayer.tv", audio=["ar.tvplayer.tv"]
)
manager._clock = lambda: 0.0 # stamp 0.0, clock 0.0 → inside TTL
info = MediaSessionInfo(package="ar.tvplayer.tv", playing=True, title="Sky News")
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
np = player._get_app_now_playing()
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
async def test_ttl_resurrect_guard_in_the_tier(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
now = 1000.0
player, manager = self._player(modules, foreground="ar.tvplayer.tv")
player._last_channel = "101"
info = MediaSessionInfo(package="ar.tvplayer.tv", playing=True, title="Sky News")
manager._clock = lambda: now # the closure reads the live `now` cell
manager._last_known["ar.tvplayer.tv"] = (info, now)
# inside TTL → last-known title served
np = player._get_app_now_playing()
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
# past STATE_STORE_TTL → 'playing' must NOT be resurrected;
# the tier falls back to the last channel instead
now = now + 61.0
np = player._get_app_now_playing()
assert np == {"state": "idle", "title": "101", "source": "app"}
class TestChannelLeakGuard:
"""F1: the channel number never leaks to a non-channel foreground app."""
async def test_launcher_never_shows_the_channel_number(self, modules):
player, _m = _ladder_player(
modules, foreground="com.spocky.projengmenu"
)
player._last_channel = "101" # entered in TiviMate earlier
np = player._get_app_now_playing()
assert np is None # the leak is gone
async def test_channel_app_still_gets_the_channel_title(self, modules):
player, _m = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
player._last_channel = "101"
np = player._get_app_now_playing()
assert np == {"state": "idle", "title": "101", "source": "app"}
async def test_audio_member_can_carry_the_channel_title(self, modules):
player, manager = _ladder_player(
modules, foreground="ar.tvplayer.tv", audio=["ar.tvplayer.tv"]
)
player._last_channel = "205"
np = player._get_app_now_playing()
assert np["title"] == "205"
class TestPrivacyOrdering:
"""F3: privacy mode outranks the channel fallback — identity stays the app."""
async def test_channel_number_never_leaks_in_privacy_mode(self, modules):
player, manager = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
player._entry.options["adb_store_app_info"] = False # privacy on
player._last_channel = "101"
np = player._get_app_now_playing()
assert np["title"] == TIVIMATE_PACKAGE # app identity, not the channel
assert np["title"] != "101"
async def test_channel_number_never_leaks_in_privacy_mode_with_last_known(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, manager = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
player._entry.options["adb_store_app_info"] = False
manager._clock = lambda: 0.0
info = MediaSessionInfo(
package=TIVIMATE_PACKAGE, playing=True, title="Sky News"
)
manager._last_known[TIVIMATE_PACKAGE] = (info, 0.0)
player._last_channel = "101"
np = player._get_app_now_playing()
assert np["state"] == "playing" # playback truth survives privacy
assert np["title"] == TIVIMATE_PACKAGE # but identity stays the app
class TestVolumeAndPowerLadder:
async def test_volume_readout_from_protocol_push(self, modules):
player, served, _intents = make_player(modules)
manager = player._manager
manager._on_volume_info({"level": 7, "max": 15, "muted": True})
assert player.volume_level == pytest.approx(7 / 15)
assert player.is_volume_muted is True
async def test_volume_set_is_not_claimed(self, modules):
"""C-VOL: VOLUME_SET stays out of supported features (readout only)."""
from nsh.media_player import _BASE_FEATURES # noqa: PLC0415
assert not (_BASE_FEATURES & (1 << 5)) # VOLUME_SET bit off
async def test_is_on_ladder_protocol_first(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
# no protocol push, no ADB wake data: fall to the plug/other logic
assert manager.is_on is None
manager._on_is_on(True)
assert player._is_power_on() is True
manager._on_is_on(False)
assert player._is_power_on() is False
async def test_adb_wake_used_when_protocol_silent(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
manager.store.set("awake", False)
# protocol silent (is_on None) → ADB wake is the decision
assert manager.is_on is None
assert player._is_power_on() is False
# ── shared helpers ──
def _remote_entity(modules):
from types import SimpleNamespace
remote_module = modules["remote"]
entry = SimpleNamespace(
options={},
data={},
title="mdarcy",
entry_id="e1",
)
manager = make_manager()
entry.runtime_data = manager
entity = remote_module.NvidiaShieldRemoteEntity.__new__(
remote_module.NvidiaShieldRemoteEntity
)
entity.hass = None
entity._entry = entry
entity._manager = manager
entity._attr_unique_id = None
entity._manager_unregistered = None
return entity
async def _noop_async(*args, **kwargs):
return None
class FakeServices:
"""hass.services stand-in recording registrations."""
def __init__(self):
self.registered: dict[tuple, object] = {}
self.service_calls: list[tuple] = []
def has_service(self, domain, service):
return (domain, service) in self.registered
def async_register(self, domain, service, func, schema=None):
self.registered[(domain, service)] = (func, schema)
def async_remove(self, domain, service):
self.registered.pop((domain, service), None)
class ServiceHarness:
"""FakeHass extension: `.services` + direct service invocation."""
def attach(self, player):
player.hass.services = FakeServices()
class TestAdbCommandService:
"""P4-2a: option-gated raw shell service on the entry."""
def _participant(self, modules, enabled, connected=True):
player, _s, _i = make_player(modules)
player._entry.options["adb_command_enabled"] = enabled
if not connected:
player._manager._poller = None
player.entity_id = "media_player.shield_test"
player._attr_unique_id = "uid_shield"
kind = entity_stublib.FakeHass()
player.hass = kind
kind.services = FakeServices()
return player, kind.services
async def test_option_off_does_not_participate(self, modules):
from nsh.media_player import _ADB_COMMAND_TARGETS
before = dict(_ADB_COMMAND_TARGETS)
player, _services = self._participant(modules, enabled=False)
# the add hook consults the entry option — off → no registration
if player._entry.options.get("adb_command_enabled", False):
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
assert before == _ADB_COMMAND_TARGETS
async def test_option_on_registers_and_runs(self, modules):
player, services = self._participant(modules, enabled=True)
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "dumpsys power | head -5"}
shell_calls = []
async def _shell(cmd):
shell_calls.append(cmd)
return "ok"
player._manager._poller.shell = _shell
await _async_adb_command_service(Call())
assert shell_calls == ["dumpsys power | head -5"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_rejects_when_adb_unavailable(self, modules):
player, _s = self._participant(modules, enabled=True, connected=False)
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "echo hi"}
# no poller → the command is not sent and nothing raises
player._manager._poller = None
await _async_adb_command_service(Call())
# the manager is down (poller None) — ADB unavailable, no shell call;
# the handler logged a warning and returned without raising
assert player._manager._poller is None
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_unregister_on_last_participant_unload(self, modules):
from nsh.media_player import (
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
mods = load_entity_modules()
player, _s, _i = make_player(mods)
player._entry.options["adb_command_enabled"] = True
player.entity_id = "media_player.shield_test"
player.hass.services = FakeServices()
_adb_command_service_ensure(player.hass)
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
assert player.hass.services.has_service("nvidia_shield", "adb_command")
_ADB_COMMAND_TARGETS.pop("media_player.shield_test")
_adb_command_service_maybe_remove(player.hass)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
class TestChannelDigitDelayConsumed:
"""t26 F3: a non-default digit delay reaches the sequence's sleep."""
async def test_non_default_delay_reaches_the_sequence_sleep(self, modules):
import asyncio
player, _s = _ladder_player(modules)
player._entry.options["channel_digit_delay"] = 0.3 # non-default
sleeps: list[float] = []
real_sleep = asyncio.sleep
mp_module = sys.modules["nsh.media_player"]
real_module_sleep = mp_module.asyncio.sleep
async def spying_sleep(delay, *args):
sleeps.append(delay)
await real_sleep(0)
mp_module.asyncio.sleep = spying_sleep
try:
await player.async_play_media("channel", "123")
if player._channel_task is not None:
await player._channel_task # the sequence runs to completion
finally:
mp_module.asyncio.sleep = real_module_sleep
# three digits → three sleeps, each with the option's 0.3 s
assert sleeps == [0.3, 0.3, 0.3]
class TestTextEntry:
"""P5-2: text entry — protocol IME first, ADB input-text fallback."""
def _player_with_texts(self, modules):
player, _s, _i = make_player(modules)
protocol = player._manager._protocol
protocol.text_calls = []
protocol.send_text = lambda t: protocol.text_calls.append(t) or True
shell_texts = []
async def _shell(cmd):
shell_texts.append(cmd)
return "ok"
player._manager._poller.shell = _shell
return player, protocol, shell_texts
async def test_protocol_ime_sends_first(self, modules):
player, protocol, shell_texts = self._player_with_texts(modules)
await player.async_play_media("text", "hello world")
assert protocol.text_calls == ["hello world"]
assert shell_texts == [] # ADB fallback not used
async def test_adb_fallback_when_the_protocol_send_fails(self, modules):
player, protocol, shell_texts = self._player_with_texts(modules)
def _fail(text):
protocol.text_calls.append(text)
raise RuntimeError("ime closed")
protocol.send_text = _fail
await player.async_play_media("text", "hello 'world'")
import shlex
sample = "hello " + chr(39) + "world" + chr(39)
assert shell_texts == [f"input text {shlex.quote(sample)}"]
async def test_empty_text_is_ignored(self, modules):
player, protocol, shell_texts = self._player_with_texts(modules)
await player.async_play_media("text", "")
assert protocol.text_calls == [] and shell_texts == []
player._manager._protocol.send_text = lambda t: True
await player.async_play_media("text", "")
assert protocol.text_calls == []
class TestIconOverride:
"""P5-2: optional icon column → CONF_APP_ICON_MAP → entity_picture."""
def _player_with_icon(self, modules, icon_map):
player, _s, _i = make_player(modules)
player._entry.options["app_icon_map"] = icon_map
return player
async def test_icon_serves_when_no_jellyfin_picture(self, modules):
import asyncio
player = self._player_with_icon(
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
)
manager = player._manager
manager._on_current_app("ar.tvplayer.tv")
await asyncio.sleep(0.05)
assert player.media_image_url == "http://icons.local/tivi.png"
async def test_no_icon_for_an_unmatched_package(self, modules):
import asyncio
player = self._player_with_icon(
modules, {"com.netflix.ninja": "http://icons.local/netflix.png"}
)
player._manager._on_current_app("ar.tvplayer.tv")
await asyncio.sleep(0.05)
assert player.media_image_url is None
async def test_jellyfin_picture_wins_over_the_icon_map(self, modules):
player = self._player_with_icon(
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
)
player._jf_active = lambda: True
player._jf_attr = lambda attr: "http://jf.local/pic.jpg"
assert player.media_image_url == "http://jf.local/pic.jpg"
class TestServiceRegistryKey:
"""F4: the participant key is computed once — add/remove always match."""
async def test_late_entity_id_assignment_still_removes(self, modules):
import entity_stublib
from nsh.media_player import (
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
mods = load_entity_modules()
player, _s, _i = make_player(mods)
player._entry.options["adb_command_enabled"] = True
player._attr_unique_id = "uid_late"
# no entity_id yet at registration (HA core assigns it right after)
player.hass = entity_stublib.FakeHass()
player.hass.services = FakeServices()
if hasattr(player, "_adb_command_key"):
del player._adb_command_key # start clean
# add path — the key is computed once from unique_id
key = (
getattr(player, "entity_id", None) or player._attr_unique_id
)
player._adb_command_key = key
_ADB_COMMAND_TARGETS[key] = player._manager
_adb_command_service_ensure(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS
# HA core assigned the entity_id afterwards — removal must still match
player.entity_id = "media_player.shield_test"
_adb_command_service_maybe_remove(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS # service stays until unload
# removal uses the stored key (does not consult the new entity_id)
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
_adb_command_service_maybe_remove(player.hass)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
class TestEntryDiagnostics:
"""P4-2b: redacted entry diagnostics with store + transport health."""
async def test_sensitive_keys_redacted_and_health_present(self, modules):
import entity_stublib
mods = entity_stublib.load_entity_modules()
player, _s, _i = make_player(mods)
from types import SimpleNamespace as DiagEntry
entry = DiagEntry(
data={},
options={
"adb_command_enabled": True,
"protocol_pairing_code": "4821",
},
title="Shield",
)
manager = player._manager
manager.store.set("awake", True)
entry.runtime_data = manager
from nsh.diagnostics import async_get_config_entry_diagnostics
payload = await async_get_config_entry_diagnostics(None, entry)
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
assert payload["entry"]["options"]["adb_command_enabled"] is True
assert "awake" in payload["store"]
assert payload["transport"]["adb"]["connected"] is not None
assert payload["transport"]["protocol"]["active"] is not None
class TestBrowseMediaDirectory:
"""P4-2c: the app directory root and its launch path."""
async def test_root_lists_the_options_map(self, modules):
player, _s, _i = make_player(modules)
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
"com.netflix.ninja": "Netflix",
}
root = await player.async_browse_media()
assert root.title == "Apps"
assert root.can_play is False and root.can_expand is True
ids = [(c.media_content_id, c.title) for c in root.children]
assert ids == [
("ar.tvplayer.tv", "TiviMate"),
("com.netflix.ninja", "Netflix"),
]
assert all(c.media_content_type == "app" for c in root.children)
async def test_root_falls_back_to_the_curated_default(self, modules):
player, _s, _i = make_player(modules) # no apps map configured
root = await player.async_browse_media()
ids = [c.media_content_id for c in root.children]
assert "ar.tvplayer.tv" in ids # TiviMate
assert "com.github.damontecres.wholphin" in ids # Wholphin
assert "com.netflix.ninja" in ids # Netflix
assert "com.google.android.youtube.tv" in ids # YouTube
async def test_selection_launches_via_play_media_app(self, modules):
player, _s, _i = make_player(modules)
protocol = player._manager._protocol
root = await player.async_browse_media()
selected = root.children[0]
await player.async_play_media("app", selected.media_content_id)
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
+217
View File
@@ -0,0 +1,217 @@
"""F1 regression: the adb_pair submit path calls
ShieldConnectionManager.test_connect with its declared signature
(host, port, key_path) — nothing extra, arity exact.
config_flow.py imports Home Assistant; it is exercised here against small
module stubs (import-only; no HA behaviour needed on this path) with the
actual ADB handshake mocked on the manager.
"""
import sys
import types
from unittest.mock import AsyncMock
import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
)
_KEY_PATH = "/tmp/nsh-test-adbkey"
_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.selector",
# Loaded execution runs beneath the stubs; never escape this file either.
"nsh.helpers",
"nsh.config_flow",
)
def _install_import_stubs() -> None:
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
if sys.modules.get("voluptuous") is not None:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
class ConfigFlow:
VERSION = 1
def __init_subclass__(cls, **kwargs):
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
super().__init_subclass__(**kwargs)
def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {})
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
def _form(step_id, errors):
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = type("OptionsFlow", (), {})
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
sys.modules["homeassistant.core"] = core
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers
selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: ""
selector.SelectSelector = lambda *a, **k: None
selector.SelectSelectorConfig = lambda **k: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
sys.modules["homeassistant.helpers.selector"] = selector
class _Config:
config_dir = "/tmp/nsh-test-config"
class _FakeHass:
config = _Config
@staticmethod
async def async_add_executor_job(fn, *args):
return fn(*args)
def _config_flow_module():
_install_import_stubs()
if "nsh.helpers" not in sys.modules:
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
if "nsh.config_flow" not in sys.modules:
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
return sys.modules["nsh.config_flow"]
def _flow(user_input_host: str, monkeypatch=None):
module = _config_flow_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = _FakeHass
flow._user_data = {
"name": "Nvidia Shield",
CONF_ADB_ENABLED: True,
CONF_ADB_HOST: user_input_host,
CONF_ADB_PORT: 5555,
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "",
CONF_POWER_ENTITY: "",
CONF_ADB_STORE_APP_INFO: True,
}
if monkeypatch is not None:
# Patch module-level resolution — the call site is a plain global.
monkeypatch.setattr(
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
)
return flow
@pytest.fixture(autouse=True)
def scoped_stubs():
"""Contain the import stubs to this module's tests (t7 INFO).
Snapshots every injected sys.modules entry and restores or removes it on
teardown, so later tests never observe the fake homeassistant/voluptuous
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
"""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
_install_import_stubs()
yield
for name in _STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.fixture
def mocked_test_connect(monkeypatch):
from nsh.connection import ShieldConnectionManager
mock = AsyncMock(return_value=True)
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
# (no key material — and no adb_shell keygen — during tests).
module = _config_flow_module()
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
return mock
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
flow = _flow("shield.local")
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_awaited_once_with(
"shield.local", 5555, _KEY_PATH
)
assert result["type"] == "create_entry"
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
flow = _flow("shield.local")
mocked_test_connect.return_value = False
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_awaited_once()
assert result["type"] == "form"
assert result["errors"] == {"base": "adb_failed"}
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
flow = _flow("") # no host resolvable
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_not_awaited()
assert result["errors"] == {"base": "adb_no_host"}
+385
View File
@@ -0,0 +1,385 @@
"""Phase 2 step 2 tests: protocol cert/PIN pairing, reauth, entry migration.
Self-contained scoped-stub variant of the config-flow test harness (same
pattern as test_config_flow_pairing.py, with pair-hygiene capabilities added:
FakeHass records unique_id sets, entry updates and reload scheduling). The
androidtvremote2 client is faked with a deterministic pairing code and MAC.
"""
import asyncio # noqa: F401 (async steps need the event loop)
import sys
import types
from types import SimpleNamespace
import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT,
)
_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.const",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.selector",
"nsh.helpers",
"nsh.config_flow",
"nsh.entry",
)
FAKE_PAIRING_CODE = "8421"
FAKE_DEVICE_NAME = "mdarcy"
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
class FakeInvalidAuthError(Exception):
"""androidtvremote2.exceptions.InvalidAuth stand-in."""
class FakeProtocolClient:
"""Proto client used by the flow pairing path (deterministic)."""
def __init__(self, fail_code=False):
self.cert_generated = False
self.pairing_started = False
self.finished_codes: list[str] = []
self.disconnected = 0
self._fail_code = fail_code
async def async_generate_cert_if_missing(self):
self.cert_generated = True
return True
async def async_start_pairing(self):
self.pairing_started = True
return FAKE_PAIRING_CODE # deterministic "device-side PIN"
async def async_finish_pairing(self, pairing_code: str) -> None:
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
raise FakeInvalidAuthError("device rejected the pairing code")
self.finished_codes.append(pairing_code)
async def async_get_name_and_mac(self):
return FAKE_DEVICE_NAME, FAKE_MAC
def disconnect(self):
self.disconnected += 1
def _install_stubs() -> None:
"""Import-time stand-ins (idempotent, scoped by the autouse fixture)."""
if sys.modules.get("voluptuous") is not None:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
class ConfigFlow:
VERSION = 1
def __init_subclass__(cls, **kwargs):
kwargs.pop("domain", None)
super().__init_subclass__(**kwargs)
def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {})
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
def async_abort(self, *, reason):
return {"type": "abort", "reason": reason}
async def async_set_unique_id(self, unique_id):
self._unique_id = unique_id
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
if getattr(self, "_unique_id", None) in active_unique_ids:
raise AbortIfConfiguredError()
def _form(step_id, errors):
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
class AbortIfConfiguredError(Exception):
pass
active_unique_ids = set()
class OptionsFlow:
pass
config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow
config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
sys.modules["homeassistant.core"] = core
ha_const = types.ModuleType("homeassistant.const")
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
class _Platform:
MEDIA_PLAYER = "media_player"
SENSOR = "sensor"
REMOTE = "remote"
BUTTON = "button"
ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers
selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: ""
selector.SelectSelector = lambda *a, **k: None
selector.SelectSelectorConfig = lambda **k: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
sys.modules["homeassistant.helpers.selector"] = selector
class FakeConfigEntries:
def __init__(self):
self.updating_entry_calls: list = []
self.reloaded_entry_ids: list[str] = []
def async_get_entry(self, entry_id):
return getattr(self, f"__entry_{entry_id}", 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)
else:
setattr(entry, key, value)
def async_schedule_reload(self, entry_id):
self.reloaded_entry_ids.append(entry_id)
class FakeHass:
def __init__(self, tmp_dir: str):
import types as t
self.config = t.SimpleNamespace(config_dir=tmp_dir)
self.config_entries = FakeConfigEntries()
@staticmethod
async def async_add_executor_job(fn, *args):
return fn(*args)
def _form_module():
_install_stubs()
if "nsh.helpers" not in sys.modules:
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
if "nsh.config_flow" not in sys.modules:
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
return sys.modules["nsh.config_flow"]
def _entry_module():
_install_stubs()
if "nsh.entry" not in sys.modules:
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
return sys.modules["nsh.entry"]
@pytest.fixture(autouse=True)
def scoped_stubs():
"""Keep the import stubs inside this module's tests (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
_install_stubs()
yield
for name in _STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
"""ConfigFlow with the real client factory mocked to our fake."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(tmp_dir)
flow._user_data = {
"name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "",
CONF_POWER_ENTITY: "",
CONF_ADB_ENABLED: False,
CONF_ADB_STORE_APP_INFO: True,
CONF_ADB_HOST: "",
CONF_ADB_PORT: DEFAULT_ADB_PORT,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "shield.local",
}
from nsh.connection import ShieldConnectionManager
monkeypatch.setattr(
ShieldConnectionManager,
"build_protocol_client",
lambda *args, **kwargs: client,
)
monkeypatch.setattr(
module,
"protocol_cert_paths",
lambda hass: ("/tmp/nsh-proto-test.pem", "/tmp/nsh-proto-test.key"),
)
return flow
class TestProtocolPairing:
async def test_first_visit_starts_pairing_and_shows_code_form(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
result = await flow.async_step_protocol_pair(None)
assert client.cert_generated is True
assert client.pairing_started is True
assert result["type"] == "form"
assert result["step_id"] == "protocol_pair"
assert result["errors"] == {}
async def test_submit_finishes_with_code_and_records_mac_unique_id(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
await flow.async_step_protocol_pair(None)
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC
assert client.disconnected == 1
assert result["type"] == "create_entry"
assert result["options"][CONF_PROTOCOL_ENABLED] is True
assert result["options"][CONF_PROTOCOL_HOST] == "shield.local"
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
await flow.async_step_protocol_pair(None)
failed = await flow.async_step_protocol_pair({"pairing_code": "9999"})
assert failed["type"] == "form"
assert failed["errors"] == {"base": "protocol_pair_failed"}
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert result["type"] == "create_entry"
assert client.finished_codes == [FAKE_PAIRING_CODE]
class TestReauthFlow:
async def test_reauth_repairs_entry_and_schedules_reload(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
entry = SimpleNamespace(
entry_id="e1", title="mdarcy",
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
data={},
)
flow.hass.config_entries.__dict__["__entry_e1"] = entry
flow.context = {"entry_id": "e1"}
shown = await flow.async_step_reauth({CONF_ADB_PORT: DEFAULT_ADB_PORT})
assert shown["type"] == "form"
assert shown["step_id"] == "reauth_confirm"
pair_form = await flow.async_step_reauth_confirm({"confirm": True})
assert pair_form["step_id"] == "protocol_pair" # PIN form open on device
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert result == {"type": "abort", "reason": "reauth_successful"}
assert entry.options[CONF_PROTOCOL_ENABLED] is True
assert flow.hass.config_entries.reloaded_entry_ids == ["e1"]
class TestEntryMigration:
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=1,
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
# ADB options carried over byte-compatibly
assert entry.options["adb_enabled"] is True
assert entry.options["adb_port"] == 5555
# Protocol fields default; host discovery (ADB fallback) stays active
assert entry.options[CONF_PROTOCOL_ENABLED] is False
assert entry.options[CONF_PROTOCOL_HOST] == ""
assert hass.config_entries.updating_entry_calls == [
(entry, {"options": entry.options, "version": 2})
]
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
options={CONF_PROTOCOL_ENABLED: True},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert hass.config_entries.updating_entry_calls == []
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=3, options={}, title="x")
assert await module.async_migrate_entry(hass, entry) is False
+767
View File
@@ -0,0 +1,767 @@
"""Unit tests for ShieldConnectionManager + StateStore (Phase 1).
Driven with a fake poller — no real ADB, no Home Assistant. Covers the
connect/reconnect backoff ladder, state expiration, listener notification,
command delegation, and full task lifecycle.
"""
import asyncio
import time
import types
import pytest
from ha_adb_common.media_session import MediaSessionInfo
from nsh import connection
from nsh.connection import (
FIELD_AWAKE,
FIELD_SESSIONS,
ShieldConnectionManager,
StateStore,
)
from nsh.const import ADB_POLL_INTERVAL
def test_connection_module_depends_on_no_homeassistant():
"""The manager module must stay importable without HA installed."""
with open(connection.__file__, encoding="utf-8") as fh:
source = fh.read()
assert "homeassistant" not in source
class FakePoller:
"""AdbMediaSessionPoller shape — canned answers, no socket."""
def __init__(self):
self.connected = False
self.connect_failures = 0
self.disconnect_calls = 0
self.awake = None
self.sessions: list[MediaSessionInfo] = []
self.apps: list[dict] = []
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:
if self.connect_failures > 0:
self.connect_failures -= 1
self.connected = False
return False
self.connected = True
return True
async def disconnect(self) -> None:
self.disconnect_calls += 1
self.connected = False
async def health_check(self) -> bool:
return self.connected
async def is_awake(self) -> bool | None:
return self.awake if self.connected else None
async def poll_media_sessions(
self, foreground_package=None, head_lines: int | None = 200
) -> MediaSessionInfo | None:
self.head_lines_used = head_lines
return self.sessions[0] if self.sessions else None
@property
def all_sessions(self) -> list[MediaSessionInfo]:
return self.sessions
@property
def last_dump(self) -> str | None:
return self.audio_dump
async def poll_running_apps(self) -> list[dict]:
return list(self.apps)
async def poll_foreground(self) -> str | None:
self.foreground_calls += 1
return self.foreground
async def shell(self, cmd: str) -> str | None:
self.shell_calls.append(cmd)
import shlex
for package, dump in self.pm_dumps.items():
if package in cmd or shlex.quote(package) in cmd:
return dump
return None
async def send_media_command(self, command: str) -> bool:
return self.connected
async def bring_to_foreground(self, package: str) -> bool:
return self.connected
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
class TestStateStore:
def test_set_get_roundtrip(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set("awake", True)
assert store.get("awake") is True
def test_expired_field_returns_default(self):
"""B5: a field older than the TTL reads as expired, not last-known."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
store.set(FIELD_AWAKE, True)
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
assert store.get(FIELD_AWAKE) is True
now[0] += 60.1
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_missing_field_returns_default(self):
store = StateStore(ttl=60, clock=time.monotonic)
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_clear_immediately_expires(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set(FIELD_AWAKE, False)
store.clear(FIELD_AWAKE)
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
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
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 # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
first_dump_count = len(manager._poller.shell_calls)
# later cycles: ZERO additional pm dumps for the unresolvable pkg
manager.app_label("com.unknown.app") # negative cache hit — not enqueued
manager.app_label("com.unknown.app")
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert len(manager._poller.shell_calls) == first_dump_count
assert manager.app_label("com.unknown.app") is None # falls through
async def test_pm_dump_command_is_shell_quoted(self):
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
import shlex
wicked = "com.evil;name"
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps[wicked] = "application-label: 'E'"
manager.app_label(wicked) # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
assert manager._poller.shell_calls == [
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300"
]
class TestLogcatStream:
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
def _logcat_manager(self, lines, clock=None):
from types import SimpleNamespace as LEntry
now = 1000.0
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
)
manager.entry = LEntry(options={"logcat_events": bool(lines)})
lines = lines or []
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None # deterministic: no real ladder sleeps (t26)
manager._logcat_ladder_sleep = fake_ladder_sleep
return manager, now
async def test_start_stamps_foreground_instantly(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
notified = []
manager.register_listener(lambda: notified.append(1))
await manager._watchdog_cycle(5.0) # starts the consumer
import asyncio
await asyncio.sleep(0) # drain the task
assert manager.store.get("foreground") == "com.netflix.ninja"
assert notified # immediate listener notification (sub-heartbeat)
async def test_duplicate_start_suppressed_inside_window(self):
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
tick = {"i": 0}
def advancing():
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
return stamps[tick["i"]]
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
],
clock=advancing,
)
store_sets = {"n": 0}
original_set = manager.store.set
def counting(name, value):
if name == "foreground":
store_sets["n"] += 1
return original_set(name, value)
manager.store.set = counting
await manager._watchdog_cycle(5.0)
await manager._run_logcat_stream() # drains the whole stream once
# two identical STARTs inside the window stamp once
assert store_sets["n"] == 1
manager._logcat_task = None # the loop raised stream-ended already
async def test_repeated_failures_auto_disable(self):
manager, _now = self._logcat_manager(
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
)
# the stream rnux: ``_open`` raises — ladder counts the failure
# starve the ladder: instant (no real sleep between reconnects)
manager._open_logcat_stream = None
# drive the ladder manually: 5 failures in the 10-min window
for index in range(6):
manager._note_logcat_failure(1000.0 + index)
assert manager._logcat_disabled is True # auto-disable at 5
assert manager._entry_options_logcat() is True # option unchanged
def test_streaming_call_carries_no_idle_timeout(self):
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "transport_timeout_s=None" in src # bulk_read blocks
assert "default_transport_timeout_s=10.0" not in src # old default
async def test_buffered_window_catches_wrapped_cmp_lines(self):
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
from types import SimpleNamespace as LEntry
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
)
manager.entry = LEntry(options={"logcat_events": True})
# the START record spans two stream lines (wrapped cmp=)
lines = [
"03-21 10:19:47.000 3104 3456 I"
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
" /.ui.MainActivity flg=0x1}",
]
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None
manager._logcat_ladder_sleep = fake_ladder_sleep
raised = None
try:
await manager._logcat_pass()
except Exception as err: # the pass ends with stream-ended
raised = err
assert raised is not None # stream-ended raises by contract
assert manager.store.get("foreground") == "ar.tvplayer.tv"
def test_public_config_accessor_is_the_source(self):
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "poller.config" in src # public accessor
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
async def test_option_off_means_no_stream_task(self):
manager, _now = self._logcat_manager(None) # logcat_events: falsy
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert manager._logcat_task is None # zero behavior change
async def test_stream_never_touches_the_poller_socket(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
await manager._watchdog_cycle(5.0)
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
class TestForegroundFallback:
async def test_stamps_field_foreground_when_protocol_silent(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 1
assert manager.store.get("foreground") == "ar.tvplayer.tv"
# store priority: protocol push wins over the ADB parse
assert manager.foreground_app == "ar.tvplayer.tv"
async def test_protocol_push_overrides_adb_foreground(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv"
manager = _manager(poller)
manager.store.set("current_app", "com.netflix.ninja") # live push
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0 # skipped — push is fresh
assert manager.foreground_app == "com.netflix.ninja"
async def test_no_foreground_query_when_asleep(self):
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0
assert manager.store.get("foreground") is None
class TestAudioPlaybackFilter:
async def test_system_media_packages_filtered(self):
poller = FakePoller()
poller.awake = True
poller.audio_dump = (
" Audio playback (lastly played comes first)\n"
" uid=1 packages=com.google.android.apps.mediashell\n"
" uid=2 packages=ar.tvplayer.tv\n"
" uid=3 packages=com.android.systemui\n"
)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.audio_playback == ["ar.tvplayer.tv"]
class TestLastKnownStateCache:
async def test_serves_last_known_when_no_session(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# later: TiviMate stops publishing; foreground has no session
poller.sessions = []
await manager._watchdog_cycle(5.0)
served = manager.best_session("ar.tvplayer.tv")
assert served is not None
assert served.playing is True # the last known state
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is served
assert stamp is not None
async def test_expiry_never_resurrects_playing(self):
"""P3-2d expiry contract, deterministic via the injected clock."""
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
assert manager.last_known_session("ar.tvplayer.tv") is not None
# TTL passes with no further caching — 'playing' must NOT come back
now += manager.store.ttl + 0.05
assert manager.last_known_session("ar.tvplayer.tv") is None
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
assert manager.best_session("ar.tvplayer.tv") is None
async def test_state_inside_ttl_is_served(self):
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
now += manager.store.ttl - 1.0 # inside the TTL
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is not None and info.playing is True
assert stamp == 1000.0 # the cycle's cache stamp
async def test_live_session_beats_last_known(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# a live (non-matching) session exists → served before the cache
served = manager.best_session("ar.tvplayer.tv")
assert served is not None and served.package == "app.old"
async def test_cache_cleared_on_stop(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
await manager.async_stop()
assert manager._last_known == {}
class TestConnectAndRefresh:
async def test_cycle_connects_and_populates_store(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
poller.apps = [{"package": "app.a"}]
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
manager = _manager(poller)
notified = []
unsub = manager.register_listener(lambda: notified.append(True))
delay = await manager._watchdog_cycle(5.0)
assert delay == ADB_POLL_INTERVAL
assert manager.connected is True
assert manager.awake is True
assert manager.best_session("app.a").package == "app.a"
assert manager.running_apps == [{"package": "app.a"}]
assert manager.audio_playback == ["app.a"]
assert notified, "listener must be notified after a refresh"
unsub()
assert manager._listeners == []
async def test_query_is_untruncated(self):
"""The manager always asks for the full dump (plan blocker B4)."""
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.head_lines_used is None
async def test_standby_expires_playback_fields(self):
"""Asleep → sessions/audio/apps are expired, awake stays False."""
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
await manager._watchdog_cycle(5.0)
assert manager.awake is False
assert manager.active_sessions == []
assert manager.audio_playback == []
assert manager.running_apps == []
async def test_awake_none_is_a_cycle_failure(self):
"""Wake check returning None must back off instead of storing unknown."""
poller = FakePoller()
poller.connected = True
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
delay = await manager._watchdog_cycle(5.0)
assert delay is not None
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
assert manager.awake is None # stale True dropped
async def test_foreground_preference_and_playing_order(self):
poller = FakePoller()
poller.awake = True
# parse order convention: playing first
poller.sessions = [
MediaSessionInfo(package="app.playing", playing=True),
MediaSessionInfo(package="app.paused", paused=True),
]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.best_session("app.paused").package == "app.paused"
assert manager.best_session().package == "app.playing"
assert manager.best_session("app.unknown").package == "app.playing"
class TestReconnectBackoff:
async def test_failed_connect_returns_backoff_scaled_delay(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert not poller.connected
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
assert manager._failures == 1
async def test_backoff_doubles_up_to_the_cap(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delays = []
backoffs = []
backoff = 5.0
for _ in range(7):
backoffs.append(backoff)
delays.append(await manager._watchdog_cycle(backoff))
backoff = min(backoff * 2, 300.0)
# F5 soundness: the previous ±25 % jitter could flake the claim
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
# jitter guarantees: each delay sits inside ITS OWN backoff's band
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
# since min next = 7.5 >= max first = 6.25), and never above the cap.
for backoff, delay in zip(backoffs, delays, strict=True):
assert 0.75 * backoff <= delay <= 1.25 * backoff
assert delays[1] >= delays[0]
assert delays[-1] <= 300.0 * 1.25
async def test_repeated_failures_do_not_spam_listeners(self):
poller = FakePoller()
poller.connect_failures = 99
manager = _manager(poller)
notified = []
manager.register_listener(lambda: notified.append(True))
for _ in range(3):
await manager._watchdog_cycle(5.0)
assert notified.count(None) == 0
assert len(notified) == 1, "down-notified exactly once until recovery"
async def test_recovery_resets_failures_and_backoff(self):
poller = FakePoller()
poller.connect_failures = 0
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0) # fresh connect → ok
assert manager._failures == 0
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
async def test_failed_wake_poll_forces_reconnect(self):
"""health_check drives the reconnect decision: a cycle where is_awake
fails closes the socket so the next cycle reconnects from scratch."""
poller = FakePoller()
poller.connected = True
poller.awake = None # wake poll fails while the flag says "connected"
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
assert poller.disconnect_calls == 1
assert poller.connected is False
assert manager._failures == 1
async def test_connect_failure_clears_stale_store_state(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
sessions = [MediaSessionInfo(package="app.a", playing=True)]
manager.store.set(FIELD_SESSIONS, sessions)
await manager._watchdog_cycle(5.0)
assert manager.awake is None
assert manager.active_sessions == []
class TestCommandDelegation:
async def test_send_media_command_requires_poller(self):
manager = _manager(poller=None)
assert manager.available is False
assert await manager.send_media_command("play") is False
async def test_send_media_command_delegates(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.send_media_command("play") is True
async def test_bring_to_foreground(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.bring_to_foreground("app.a") is True
class TestLifecycle:
async def test_passive_manager_exits_immediately(self):
"""No factory → no watchdog loop; entry stays functional without ADB."""
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
await manager.async_start()
await asyncio.sleep(0.01)
assert manager.available is False
assert manager.connected is False
assert await manager.send_media_command("play") is False
await manager.async_stop()
async def test_async_stop_disconnects_and_cancels_tasks(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager.async_start()
await asyncio.sleep(0) # let the watchdog task start
assert manager.available
await manager.async_stop()
assert poller.disconnect_calls == 1
assert manager._poller is None
assert manager._tasks == []
assert manager._stopping is True
async def test_stop_is_idempotent(self):
manager = _manager(FakePoller())
await manager.async_stop()
await manager.async_stop()
async def test_factory_used_when_no_poller_given(self):
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller_factory=lambda: poller
)
await manager.async_start()
await asyncio.sleep(0)
await manager.async_stop()
assert poller.disconnect_calls == 1
class TestStoreTtlInManager:
async def test_expired_session_reads_as_none(self):
"""Full manager view: TTL expiry means 'state unavailable', not
'last known playing'."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
manager = _manager(store=store)
manager.store.set(
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
)
now[0] += 60.1
assert manager.best_session() is None
@pytest.mark.parametrize("owned", [True, False])
async def test_watchdog_cycle_never_raises(owned):
"""A poller can throw anything — the cycle converts to a backoff delay."""
poller = FakePoller()
class ExplodingPoller(FakePoller):
async def is_awake(self):
raise RuntimeError("boom")
manager = _manager(ExplodingPoller() if owned else poller)
delay = await manager._watchdog_cycle(5.0)
assert delay > 0
+76
View File
@@ -0,0 +1,76 @@
"""P3-2a tests: the mResumedActivity foreground parser (RESEARCH_SHIELD R4).
Fixture-driven from the on-device captures; the launcher case comes from the
R4-documented custom-launcher capture. Window-focus parsing is never used
(R4 finding 2: mCurrentFocus=null trap).
"""
from pathlib import Path
from nsh.ha_adb_common import parse_foreground
FIXTURES = Path(__file__).parent / "fixtures"
# R4: the on-device capture (grep -iE 'resumed') — the wrapped ActivityRecord
# plus tolerated Resumed/ResumedActivity duplicates.
_TIVIMATE_DUMP = (FIXTURES / "activity_activities_tivimate.txt").read_text()
def test_tivimate_fixture_primary_pattern():
assert parse_foreground(_TIVIMATE_DUMP) == "ar.tvplayer.tv"
def test_launcher_foreground_from_r4_capture():
# R4 finding 4: the user's custom launcher is a legitimate foreground app.
dump = (
"mResumedActivity: ActivityRecord{abcd1234 u0 com.spocky.projengmenu"
"/.ui.home.MainActivity\n"
" state=RESUMED stopped=false delayedResume=false finishing=false\n"
)
assert parse_foreground(dump) == "com.spocky.projengmenu"
def test_multiline_wrap_joined_before_matching():
# The wrap splits right after <pkg>/<activity>; the package must survive.
dump = (
" mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity\n"
" t8152}\n"
)
assert parse_foreground(dump) == "ar.tvplayer.tv"
def test_topresumed_only_fallback():
# Newer firmware: no mResumedActivity, only topResumedActivity (checked after).
dump = (
" topResumedActivity: ActivityRecord{aa11bb22 u0 com.other.app/.Main t1}\n"
)
assert parse_foreground(dump) == "com.other.app"
def test_topresumed_never_beats_primary():
dump = (
" mResumedActivity: ActivityRecord{x u0 primary.pkg/.Main t1}\n"
" topResumedActivity: ActivityRecord{y u0 newer.pkg/.Main t2}\n"
)
assert parse_foreground(dump) == "primary.pkg"
def test_empty_and_garbage_return_none():
assert parse_foreground("") is None
assert parse_foreground("some random log lines\nwithout any resumed marker") is None
def test_resumed_duplicates_tolerated():
# Resumed: / ResumedActivity: / mResumedActivity: all describe the same app.
dump = (
" Resumed activities in task display areas (from top to bottom):\n"
" Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
" ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
"mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
)
assert parse_foreground(dump) == "ar.tvplayer.tv"
def test_null_package_never_returned():
dump = "mResumedActivity: ActivityRecord{4f0d030 u0 null/null t1}\n"
assert parse_foreground(dump) is None
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the PlaybackIntent dataclass (optimistic state tracking)."""
from ha_adb_common.intent import PlaybackIntent
def test_create_captures_fields():
intent = PlaybackIntent.create(
target_state="paused", command="media_pause", prior_state="playing"
)
assert intent.target_state == "paused"
assert intent.command == "media_pause"
assert intent.prior_state == "playing"
assert intent.retry_count == 0
assert intent.created_at > 0
def test_debug_dict_shape():
intent = PlaybackIntent.create(target_state="playing", command="media_play")
debug = intent.to_debug_dict()
assert debug["target"] == "playing"
assert debug["command"] == "media_play"
assert debug["retries"] == 0
assert "age_s" in debug
def test_age_increases():
import time
intent = PlaybackIntent.create(target_state="playing", command="media_play")
before = intent.age_seconds
time.sleep(0.01)
assert intent.age_seconds >= before
+254
View File
@@ -0,0 +1,254 @@
"""Parser tests driven by real Shield firmware captures (Android 11, RQ1A.210105.003).
Fixtures live in tests/fixtures — see docs/RESEARCH_SHIELD.md for how each
capture was produced and which expectations it pins.
"""
from pathlib import Path
from ha_adb_common.media_session import (
MediaSessionInfo,
parse_all_media_sessions,
parse_audio_playback,
parse_media_session,
)
FIXTURES = Path(__file__).parent / "fixtures"
def _fixture(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
class TestTiviMateHasNoSession:
"""TiviMate (ar.tvplayer.tv) registers no MediaSession on this firmware."""
def test_no_sessions_parsed(self):
assert parse_all_media_sessions(_fixture("media_session_tivimate.txt")) == []
def test_session_query_returns_none(self):
dump = _fixture("media_session_tivimate.txt")
assert parse_media_session(dump, "ar.tvplayer.tv") is None
def test_inactive_stale_session_is_not_surfaced(self):
"""Netflix registers an inactive, state-less session; the parser must
ignore it — an active=false block never appears in the results."""
dump = _fixture("media_session_tivimate.txt")
assert parse_media_session(dump, "com.netflix.ninja") is None
class TestWholphinSession:
"""Wholphin (com.github.damontecres.wholphin) publishes a proper session."""
def test_playing_session_parsed(self):
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert len(sessions) == 1
session = sessions[0]
assert session.package == "com.github.damontecres.wholphin"
assert session.playing is True
assert session.paused is False
assert session.title == "Twirlywoos"
assert session.artist is None
def test_foreground_package_is_preferred(self):
dump = _fixture("media_session_wholphin.txt")
best = parse_media_session(dump, "com.github.damontecres.wholphin")
assert best is not None
assert best.package == "com.github.damontecres.wholphin"
assert best.playing is True
class TestPlaybackStatePosition:
"""PlaybackState carries position (ms), speed, and uptime-ms `updated` (R9)."""
def test_wholphin_position_fields_parsed(self):
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert len(sessions) == 1
session = sessions[0]
assert session.position == 17237
assert session.speed == 1.0
assert session.updated == 1408392
def test_buffered_position_is_not_mistaken_for_position(self):
"""`buffered position=70070` sits in the same block; only the real
`position=17237` may be read."""
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert sessions[0].position != 70070
def test_position_at_uptime_extrapolates_with_speed(self):
session = MediaSessionInfo(
position=17237, speed=1.0, updated=1408392, playing=True
)
assert session.position_at_uptime(1408392 + 1000) == 18237
assert session.position_at_uptime(1408392) == 17237
def test_position_at_uptime_none_without_sample(self):
assert MediaSessionInfo().position_at_uptime(1_000_000) is None
def test_paused_session_parses_state_and_position(self):
dump = (
"Sessions Stack - have 1 sessions:\n"
" Some app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=4200, buffered position=9000, "
"speed=0.0, updated=500, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=2, description=Song Title, Some Artist\n"
" Audio playback (lastly played comes first)\n"
" uid=10100 packages=com.example.player\n"
)
sessions = parse_all_media_sessions(dump)
assert len(sessions) == 1
session = sessions[0]
assert session.paused is True
assert session.playing is False
assert session.position == 4200
assert session.speed == 0.0
assert session.updated == 500
assert session.title == "Song Title"
assert session.artist == "Some Artist"
def test_stateless_playbackstate_line_ignored(self):
"""A non-PlaybackState `state=` line must not crash or fabricate state."""
dump = (
" Some app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=null\n"
" metadata: size=1, description=Only Title\n"
)
sessions = parse_all_media_sessions(dump)
assert len(sessions) == 1
assert sessions[0].playing is False
assert sessions[0].paused is False
assert sessions[0].position is None
class TestAudioPlaybackSection:
"""The "Audio playback (lastly played comes first)" section lists apps
that recently played audio even with no MediaSession (R2)."""
def test_tivimate_shows_up_without_a_session(self):
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
assert packages == [
"ar.tvplayer.tv",
"com.google.android.apps.mediashell",
]
def test_order_is_dump_order_most_recent_first(self):
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
assert packages[0] == "ar.tvplayer.tv"
def test_wholphin_audio_playback(self):
assert parse_audio_playback(_fixture("media_session_wholphin.txt")) == [
"com.github.damontecres.wholphin"
]
def test_lines_before_the_section_are_ignored(self):
"""`... package:` listener lines precede the section and must not leak
into the audio-playback package list."""
dump = (
" Media key listener package:\n"
" OnMediaKeyEventDispatchedListener: added 0 listener(s)\n"
" Audio playback (lastly played comes first)\n"
" uid=10117 packages=ar.tvplayer.tv\n"
)
assert parse_audio_playback(dump) == ["ar.tvplayer.tv"]
def test_empty_dump(self):
assert parse_audio_playback("") == []
def test_running_apps_last_known_enrichment():
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
tasks = [
{"package": "ar.tvplayer.tv"},
{"package": "com.netflix.ninja"},
]
sessions = [MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")]
last_known = {
"ar.tvplayer.tv": MediaSessionInfo(
package="ar.tvplayer.tv", playing=False, paused=True, title="Last show"
)
}
apps = running_apps_from_tasks(tasks, sessions, foreground_package=None, last_known=last_known)
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
assert tivi["state"] == "running" # no live session
assert tivi["last_known_state"] == "paused"
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
def _logcat_lines():
"""Synthetic logcat list covering START shapes + noise + wrapping."""
return [
"03-21 10:19:40.100 3104 3456 I NotificationService: noise",
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u0 "
"{cmp=com.netflix.ninja/com.netflix.ui.main flg=0x10000000}",
"03-21 10:19:45.100 3104 3456 I ActivityTaskManager: START u0 {cmp=null}",
"03-21 10:19:47.000 3104 3456 I ActivityTaskManager: START u10 "
"{cmp=ar.tvplayer.tv/.ui.MainActivity",
" xflg=214 abi=arm64-v8a}",
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
]
def test_logcat_start_records_parse_cmp():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat, foreground_packages
lines = _logcat_lines()
assert foreground_packages(lines) == ["com.netflix.ninja", "ar.tvplayer.tv"]
# LAST valid START wins
assert foreground_from_logcat(lines) == "ar.tvplayer.tv"
def test_logcat_non_start_and_null_ignored():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
assert foreground_from_logcat([
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
"plain noise",
]) is None
lines = _logcat_lines()
assert foreground_from_logcat(lines) == "ar.tvplayer.tv" # null skipped
def test_logcat_multi_user_prefix_tolerated():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
assert foreground_from_logcat(
["03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u123 {cmp=net.whatsapp/"] + [
" flg=0x1}"]
) == "net.whatsapp"
+364
View File
@@ -0,0 +1,364 @@
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
Fake client mirrors AndroidTVRemote's surface (callback registration,
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
against the real library's VolumeInfo TypedDict when the package is present
(`uvx --with androidtvremote2 pytest`).
"""
import asyncio
import sys
import time
import pytest
from nsh.connection import (
FIELD_AWAKE,
FIELD_IS_AVAILABLE,
FIELD_IS_ON,
FIELD_SESSIONS,
FIELD_VOLUME_INFO,
ShieldConnectionManager,
StateStore,
volume_tuple,
)
class FakeProtocolClient:
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
def __init__(self):
self._is_on_callbacks: list = []
self._is_available_callbacks: list = []
self._current_app_callbacks: list = []
self._volume_callbacks: list = []
self._invalid_auth_callback = None
self._reconnect_task = None
self.disconnect_calls = 0
# -- registration (mirrors the real API) --
def add_is_on_updated_callback(self, callback):
self._is_on_callbacks.append(callback)
def add_is_available_updated_callback(self, callback):
self._is_available_callbacks.append(callback)
def add_current_app_updated_callback(self, callback):
self._current_app_callbacks.append(callback)
def add_volume_info_updated_callback(self, callback):
self._volume_callbacks.append(callback)
# -- pushes (test helper: simulates device-side messages) --
def push_is_on(self, on):
for cb in list(self._is_on_callbacks):
cb(on)
def push_is_available(self, available):
for cb in list(self._is_available_callbacks):
cb(available)
def push_current_app(self, app_package):
for cb in list(self._current_app_callbacks):
cb(app_package)
def push_volume_info(self, info):
for cb in list(self._volume_callbacks):
cb(info)
# -- lifecycle --
def keep_reconnecting(self, invalid_auth_callback=None):
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
self._invalid_auth_callback = invalid_auth_callback
loop = asyncio.get_event_loop()
self._reconnect_task = loop.create_task(self._reconnect_loop())
async def _reconnect_loop(self):
while True: # the real task sleeps through its internal backoff
await asyncio.sleep(0.05)
def fire_invalid_auth(self):
self._invalid_auth_callback()
def disconnect(self):
self.disconnect_calls += 1
if self._reconnect_task is not None:
self._reconnect_task.cancel()
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
async def _drain_loop():
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
await asyncio.sleep(0.01)
class TestPushCallbackWiring:
async def test_is_on_and_availability_push_into_store(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
notified = []
manager.register_listener(lambda: notified.append(True))
await manager._start_protocol()
client.push_is_on(True)
client.push_is_available(False)
await _drain_loop()
assert manager.is_on is True
assert manager.is_available is False
assert manager.store.get(FIELD_IS_ON) is True
assert notified, "listeners pinged from protocol pushes"
await manager.async_stop()
async def test_current_app_push_stored_raw_package(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.push_current_app("com.netflix.ninja")
await _drain_loop()
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
await manager.async_stop()
async def test_volume_push_normalized_to_level_max_muted(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.push_volume_info({"level": 7, "max": 15, "muted": True})
await _drain_loop()
assert manager.volume_info == (7.0, 15.0, True)
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
await manager.async_stop()
class TestInvalidAuthHook:
async def test_invalid_auth_invokes_entry_callback(self):
client = FakeProtocolClient()
fired = []
manager = _protocol_manager(
client, invalid_auth_callback=lambda: fired.append(True)
)
await manager._start_protocol()
client.fire_invalid_auth()
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
await manager.async_stop()
async def test_invalid_auth_without_callback_is_safe(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.fire_invalid_auth() # no callback registered — must not raise
await manager.async_stop()
class TestLifecycle:
async def test_stop_disconnects_lib_owned_reconnect_task(self):
"""The library owns keep_reconnecting's task; closing the client
tears it down — the manager never wraps that task itself."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
task = client._reconnect_task
assert task is not None
await manager.async_stop()
await _drain_loop() # let the lib-owned task settle its cancellation
assert client.disconnect_calls == 1
assert task.cancelled()
assert manager._protocol is None
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
client = FakeProtocolClient()
built = []
def fake_builder(*args, **kwargs):
built.append(kwargs)
return client
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
manager = ShieldConnectionManager(
hass=None,
entry=None,
protocol_settings={
"host": "shield.local",
"client_name": "Home Assistant",
"certfile": "/tmp/cert.pem",
"keyfile": "/tmp/key.pem",
},
)
await manager._start_protocol()
assert manager._protocol is client
assert built
kwargs = built[0]
assert kwargs["host"] == "shield.local"
assert kwargs["client_name"] == "Home Assistant"
assert kwargs["certfile"] == "/tmp/cert.pem"
assert kwargs["keyfile"] == "/tmp/key.pem"
await manager.async_stop()
async def test_passive_without_protocol_configuration(self):
manager = _protocol_manager(client=None)
await manager._start_protocol() # no factory, no settings → no-op
assert manager._protocol is None
assert manager.is_on is None
assert manager.is_available is None
assert manager.current_app is None
assert manager.volume_info is None
await manager.async_stop()
class TestPushEdgeCases:
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
"""Push callbacks that outlive a closed loop must not crash."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
notified = []
manager.register_listener(lambda: notified.append(True))
await manager._start_protocol()
loop = asyncio.new_event_loop()
loop.close()
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
client.push_is_on(True)
await _drain_loop()
assert manager.is_on is True # store stamped via direct-notify path
assert notified # listeners still pinged synchronously
await manager.async_stop()
async def test_store_ttl_applies_to_protocol_fields(self):
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
client = FakeProtocolClient()
manager = _protocol_manager(client, store=store)
await manager._start_protocol()
client.push_is_on(True)
await _drain_loop()
now[0] += 60.1
assert manager.is_on is None # expired like every other store field
assert manager.store.get(FIELD_IS_AVAILABLE) is None
await manager.async_stop()
async def test_pushes_are_stamped_with_transition_time(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
before = time.monotonic()
client.push_is_available(True)
await _drain_loop()
after = time.monotonic()
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
assert before <= stamped_at <= after
await manager.async_stop()
class FakeAdbForOutage:
"""ADB poller stub for the outage test (wake poll fails)."""
def __init__(self):
self.connected = True
self.awake = None
self.disconnect_calls = 0
async def connect(self):
self.connected = True
return True
async def disconnect(self):
self.disconnect_calls += 1
self.connected = False
async def is_awake(self):
return self.awake if self.connected else None
class TestAdbOutageKeepsProtocolTruth:
async def test_note_failure_clears_only_adb_fields(self):
"""F2: an ADB outage must not clobber device-reported protocol truth."""
poller = FakeAdbForOutage()
poller.connected = True
poller.awake = None # wake poll fails → cycle failure path
client = FakeProtocolClient()
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, protocol=client
)
await manager._start_protocol()
client.push_is_on(True)
client.push_is_available(True)
client.push_current_app("com.netflix.ninja")
client.push_volume_info({"level": 5, "max": 15, "muted": False})
manager.store.set(FIELD_AWAKE, True)
manager.store.set(FIELD_SESSIONS, ["session-data"])
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
# ADB-derived fields cleared (regenerated by the watchdog)
assert manager.store.get(FIELD_AWAKE) is None
assert manager.store.get(FIELD_SESSIONS, []) == []
# protocol push truth survives the ADB outage (TTL expiry rules it)
assert manager.is_on is True
assert manager.is_available is True
assert manager.current_app == "com.netflix.ninja"
assert manager.volume_info == (5.0, 15.0, False)
await manager.async_stop()
class TestImportGuard:
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
"""F1: a protocol-enabled entry without androidtvremote2 keeps
running ADB-only — the guarded factory logs a warning and builds
nothing instead of raising at setup."""
import logging
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
with caplog.at_level(logging.WARNING):
client = ShieldConnectionManager.build_protocol_client(
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
)
assert client is None
assert any("degrades to ADB-only" in r.message for r in caplog.records)
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
manager = ShieldConnectionManager(
hass=None,
entry=None,
protocol_settings={
"host": "shield.local",
"client_name": "HA",
"certfile": "/tmp/c.pem",
"keyfile": "/tmp/k.pem",
},
)
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
await manager._start_protocol()
assert manager._protocol is None # passive, no crash
await manager.async_stop()
class TestVolumeTuple:
def test_normalizes_typed_dict(self):
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
def test_defaults_on_partial_dict(self):
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
def test_none_and_garbage_yield_none(self):
assert volume_tuple(None) is None
assert volume_tuple("garbage") is None
def test_accepts_real_library_volume_info(self):
androidtvremote2 = pytest.importorskip("androidtvremote2")
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
assert volume_tuple(info) == (3.0, 15.0, True)
+40
View File
@@ -0,0 +1,40 @@
"""Tests for the running-apps list builder (recent tasks + media sessions)."""
from ha_adb_common.running_apps import running_apps_from_tasks
def _session(package: str, playing: bool = False, title: str | None = None):
from ha_adb_common.media_session import MediaSessionInfo
s = MediaSessionInfo(package=package)
s.playing = playing
s.paused = not playing
s.title = title
return s
def test_returns_none_for_fewer_than_two_tasks():
assert running_apps_from_tasks([]) is None
assert running_apps_from_tasks([{"package": "app.a"}]) is None
def test_marks_foreground_and_defaults_to_running():
tasks = [{"package": "app.a"}, {"package": "app.b"}]
apps = running_apps_from_tasks(tasks, [], foreground_package="app.b")
assert apps is not None
assert len(apps) == 2
by_pkg = {a["package"]: a for a in apps}
assert "foreground" not in by_pkg["app.a"]
assert by_pkg["app.b"]["foreground"] is True
assert by_pkg["app.a"]["state"] == "running"
def test_enriches_with_media_session_state():
tasks = [{"package": "app.a"}, {"package": "app.b"}]
sessions = [_session("app.a", playing=True, title="Song")]
apps = running_apps_from_tasks(tasks, sessions)
assert apps is not None
by_pkg = {a["package"]: a for a in apps}
assert by_pkg["app.a"]["state"] == "playing"
assert by_pkg["app.a"]["title"] == "Song"
assert by_pkg["app.b"]["state"] == "running"
+879
View File
@@ -0,0 +1,879 @@
"""P3-1 tests: standalone setup — optional androidtv entity + app fallback.
The integration no longer hard-requires an external androidtv media_player:
the protocol current-app push can be the only state source. Covers the flow
validation (no state source → form error), the standalone pairing route,
entry data omitting the field, media_player app_id/app_name fallback, and
migration validity for v2 entries without the field.
"""
import asyncio # noqa: F401 (async steps need the event loop)
import sys
import types
from types import SimpleNamespace
import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
)
_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.const",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.selector",
"nsh.helpers",
"nsh.config_flow",
"nsh.entry",
)
def _install_stubs() -> None:
"""Import-time stand-ins (idempotent; same shape as the pairing tests)."""
if sys.modules.get("voluptuous") is not None:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
class ConfigFlow:
VERSION = 1
def __init_subclass__(cls, **kwargs):
kwargs.pop("domain", 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 {}),
"data_schema": data_schema,
}
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
def async_abort(self, *, reason):
return {"type": "abort", "reason": reason}
async def async_set_unique_id(self, unique_id):
self._unique_id = unique_id
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
config_entries.OptionsFlow = OptionsFlow
config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
sys.modules["homeassistant.core"] = core
ha_const = types.ModuleType("homeassistant.const")
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
class _Platform:
MEDIA_PLAYER = "media_player"
SENSOR = "sensor"
REMOTE = "remote"
BUTTON = "button"
ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
entity_registry.async_get = lambda hass: SimpleNamespace(entities={})
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers
selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: ""
selector.SelectSelector = lambda *a, **k: None
selector.SelectSelectorConfig = lambda **k: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
sys.modules["homeassistant.helpers.selector"] = selector
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", "unique_id"):
setattr(entry, key, value)
def async_schedule_reload(self, entry_id):
self.reloaded_entry_ids.append(entry_id)
class FakeHass:
def __init__(self, tmp_dir: str, states: dict | None = None):
self.config = SimpleNamespace(config_dir=tmp_dir)
self.config_entries = FakeConfigEntries()
self._states = states or {}
class _States:
def get(self, entity_id):
return self._states.get(entity_id)
self._states_ref = _States()
self._states_ref._states = self._states
self.states = self._states_ref
@staticmethod
async def async_add_executor_job(fn, *args):
return fn(*args)
@pytest.fixture(autouse=True)
def scoped_stubs():
"""Keep the import stubs inside this module's tests (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
_install_stubs()
yield
for name in _STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def _form_module():
_install_stubs()
if "nsh.helpers" not in sys.modules:
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
if "nsh.config_flow" not in sys.modules:
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
return sys.modules["nsh.config_flow"]
def _entry_module():
_install_stubs()
if "nsh.entry" not in sys.modules:
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
return sys.modules["nsh.entry"]
def _user_input(**overrides) -> dict:
base = {
"name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_PROTOCOL_ENABLED: False,
CONF_PROTOCOL_HOST: "",
}
base.update(overrides)
return base
class TestStandaloneValidation:
async def test_neither_source_shows_form_error(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "no_state_source"}
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(_user_input())
assert result["type"] == "create_entry"
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
flow._user_data = {}
from nsh.connection import ShieldConnectionManager
class _Client:
async def async_generate_cert_if_missing(self):
return True
async def async_start_pairing(self):
return "8421"
monkeypatch.setattr(
ShieldConnectionManager, "build_protocol_client",
lambda *a, **k: _Client(),
)
monkeypatch.setattr(
module, "protocol_cert_paths", lambda hass: ("/tmp/a.pem", "/tmp/a.key")
)
result = await flow.async_step_user(
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
)
assert result["type"] == "form"
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
"""No androidtv entity → the key is absent from entry data (not "")."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
flow._user_data = {
"name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "",
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "shield.local",
}
# skip the pairing route: drive _create_entry directly
result = flow._create_entry()
assert result["type"] == "create_entry"
assert CONF_ANDROIDTV_ENTITY not in result["data"]
class TestAdbOnlyStandalone:
async def test_adb_only_standalone_is_valid(self, tmp_path):
"""F2: ADB media-session mode is a third accepted state source."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(
_user_input(
**{
CONF_ANDROIDTV_ENTITY: "",
CONF_PROTOCOL_ENABLED: False,
"adb_enabled": True,
}
)
)
# routed into ADB pairing — no no_state_source error
assert result["type"] == "form"
assert result["step_id"] == "adb_pair"
assert result["errors"] == {}
async def test_error_still_fires_with_no_source_at_all(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False})
)
assert result["errors"] == {"base": "no_state_source"}
class TestMigrationKeepsOrAllowsAbsence:
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=1,
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
options={},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
# options were rewritten with the protocol defaults; the data dict —
# including the androidtv field — was never touched
assert hass.config_entries.updating_entry_calls == [
(
entry,
{
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
"version": 2,
},
)
]
async def test_v2_without_the_field_is_valid(self, tmp_path):
"""Standalone entries born v2 must pass through migration untouched."""
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
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 TestAppIconColumn:
"""P5-2: the editor's optional icon column (package=name|icon)."""
async def test_two_column_line_stores_the_icon(self, tmp_path):
module = _form_module()
entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry)
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
async def test_single_column_map_stays_valid(self, tmp_path):
module = _form_module()
entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry)
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
async def test_prefill_round_trips_the_icon_column(self):
module = _form_module()
# the serializer writes the second column through on prefill
text = module._app_map_to_text(
{"ar.tvplayer.tv": "TiviMate"},
{"ar.tvplayer.tv": "http://i.local/t.png"},
)
assert text == "ar.tvplayer.tv=TiviMate|http://i.local/t.png"
# and the parser round-trips it back
icons: dict[str, str] = {}
app_map = module._app_map_from_text(text, icons)
assert app_map == {"ar.tvplayer.tv": "TiviMate"}
assert icons == {"ar.tvplayer.tv": "http://i.local/t.png"}
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
module = _form_module()
entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry)
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
class TestChannelEntryOptions:
"""P5-2: digit-delay + optional send-enter on the options page."""
def _options_flow(self, tmp_path, options):
from types import SimpleNamespace as DefaultEntry
module = _form_module()
entry = DefaultEntry(options=dict(options) if options else {})
flow = module.NvidiaShieldOptionsFlow(entry)
hass = FakeHass(str(tmp_path))
flow.hass = hass
flow._options = dict(options) if options else {}
return module, flow
async def test_digit_delay_and_send_enter_store(self, tmp_path):
module, flow = self._options_flow(tmp_path, options={})
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
assert flow._options["channel_digit_delay"] == 0.3
assert flow._options["channel_send_enter"] is True
async def test_digit_delay_out_of_window_rejects(self, tmp_path):
module, flow = self._options_flow(tmp_path, options={})
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
async def test_digit_delay_not_a_number_rejects(self, tmp_path):
module, flow = self._options_flow(tmp_path, options={})
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "slow"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
class TestLegacyUniqueIdMigration:
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
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_mac_collision_falls_back_to_entry_id(self, tmp_path):
"""t26 F4: an existing entry owning the target MAC blocks the move."""
from types import SimpleNamespace as MigrationEntry
import entity_stublib
entity_stublib.load_entity_modules()
modules = sys.modules["nsh.media_player"]
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()
class _Entries:
entries = [
SimpleNamespace(entry_id="cfgid999", unique_id="aa:bb:cc:dd:ee:ff")
]
updating_entry_calls: list = []
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", "unique_id"):
setattr(entry, key, value)
entries = _Entries()
entries.hass = FakeHass(str(tmp_path))
hass = entries.hass
hass.config_entries = entries
await modules._migrate_legacy_unique_id(hass, entry)
# collision → the config-entry id is used instead
assert entry.unique_id == "cfgid123"
async def test_free_mac_is_taken_without_collision(self, tmp_path):
"""t26 F4: no other entry holds the MAC → the MAC is used."""
from types import SimpleNamespace as MigrationEntry
import entity_stublib
entity_stublib.load_entity_modules()
modules = sys.modules["nsh.media_player"]
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()
hass = FakeHass(str(tmp_path))
hass.config_entries.entries = [
SimpleNamespace(entry_id="cfgid999", unique_id="other:mac") # different MAC
]
await modules._migrate_legacy_unique_id(hass, entry)
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # free MAC taken
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."""
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
# both hosts already hold the discovered address: nothing changes
entry = SimpleNamespace(
unique_id="aa:bb:cc:dd:ee:ff",
options={
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "192.168.1.50",
"adb_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 == []
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path):
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery."""
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"}
# only the adb host was filled (self-heal), protocol already current:
update = hass.config_entries.updating_entry_calls[0][1]["options"]
assert update["adb_host"] == "192.168.1.50"
assert update["protocol_host"] == "192.168.1.50"
assert hass.config_entries.reloaded_entry_ids == ["e1"]
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
"""F2: an explicit ADB override survives a protocol-host IP change."""
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", # stale (IP moved)
"adb_host": "10.0.0.9", # deliberate ADB override
},
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"}
update = hass.config_entries.updating_entry_calls[0][1]["options"]
assert update["protocol_host"] == "192.168.1.50" # protocol followed
assert update["adb_host"] == "10.0.0.9" # override untouched
assert hass.config_entries.reloaded_entry_ids == ["e1"]
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_serves_when_no_higher_tier_matches(self):
"""P5-1: the entity tier is below map/manifest — it serves when the
package has no explicit override and no manifest label yet."""
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"] = {} # no override
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)
# no cached label yet → falls through to the entity tier
assert player.app_name == "ATV Tivi"