111 Commits
Author SHA1 Message Date
James Mackay 60d426945b Merge branch 'master' of https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield
Release / release (push) Successful in 7s
Test / test (push) Failing after 5s
2026-10-10 08:31:15 +01:00
James Mackay c20146b08b fix(init,config_flow): F4 — seed blocks and the import prefill honor the block's shape: ADB only when the block carries an adb host, protocol-only blocks keep adb_enabled off; TestSeedBlockShapeF4 pins all three paths (t32) 2026-10-09 08:04:41 +01:00
James Mackay 6087566336 fix(config_flow): F2 — MAC-less zeroconf discovery calls the audited _async_handle_discovery_without_unique_id helper and guards against a same-host duplicate prefill, so re-discovery can't stack entries; the import guard reads entries via the base _async_current_entries helper; TestMacLessDiscoveryF2 pins helper call + one-entry outcome (t32) 2026-10-09 08:04:05 +01:00
James Mackay 8f40b6435c fix(poller): F1 — the poller factory re-reads entry.options at call time (audit-sanctioned option), so the next built poller targets the NEW address after a zeroconf IP change; the stale live-read comment in config_flow points at the actual mechanism; TestIpChangeSelfHealF1 drives an IP change and pins the next poller host (t32) 2026-10-09 08:03:28 +01:00
James Mackay c1a82a2fa5 fix(diagnostics): hosts join the explicit TO_REDACT list per the audit's G14 citation (t30 G14) 2026-10-09 07:47:12 +01:00
James Mackay 782d749b4a docs(readme): sensor names follow the translation_key entity naming (t30 G7) 2026-10-09 07:47:02 +01:00
James Mackay d004e73d99 fix(tests): drop the speculative config-entries install guard — the scoped-stub teardowns already isolate per file; full suite green both variants (completes t30 G6) 2026-10-09 07:46:53 +01:00
James Mackay e77bc12d81 test: cross-file stub-scoping repairs — hub ConfigFlow base carries the full plumbing, standalone rebuilds when its holder is gone, test_init scoped hygiene mirrors scoped_stubs (completes t30 G6) 2026-10-09 07:38:24 +01:00
James Mackay 80ddff313a test(init): tests/test_init.py lands — domain async_setup units + YAML seed parsing/normalization, flow-init seeding records, hostless drop, duplicate guards (UI host + virtual unique_id) and single-visit interactive routing; scoped stub hygiene mirrors scoped_stubs (t30 G6/U1) 2026-10-09 07:34:57 +01:00
James Mackay 297f8a21a7 docs(readme,strings): YAML seed-only semantics documented — interactive flow, duplicate guards, no implicit entry changes on block removal (t30 U1) 2026-10-09 07:24:52 +01:00
James Mackay a6eeae86e9 feat:init,config_flow: YAML seed-only setup — an optional nvidia_shield block starts interactive SOURCE_IMPORT flows (parsed in async_setup), the import step guards duplicates by host/MAC-virtual unique_id and never re-seeds UI-configured hosts, protocol fields are prefill hints only (t30 U1) 2026-10-09 07:24:39 +01:00
James Mackay 2e70587871 feat: standards polish — G7 sensor translation_key naming + entity strings, G10 protocol-push device identity on the registry card, G12 unrecorded poll-driven attributes (media player DAG internals + sensors), G14 async_redact_data with an explicit redaction list behind the marker scan (t30) 2026-10-09 07:22:23 +01:00
James Mackay 194a97ce34 feat(button,media_player): Pair ADB carries entity_category CONFIG (t30 G11) + BrowseMediaSource domain parameter at root and children (t30 G18, 2026-05-20 blog contract); stubs/tests pin both 2026-10-09 07:17:14 +01:00
James Mackay 7c12d96070 feat(icon): HACS-required 256x256 icon.png placeholder, generated (t30 G15) 2026-10-09 07:15:42 +01:00
James Mackay a5254b4740 feat(manifest): integration_type device + domain loggers adb_shell/androidtvremote2 (t30 G1) 2026-10-09 07:15:42 +01:00
James Mackay 383ec5ada5 feat(config_flow): commit the G2 + stub wiring lost in the amend cycle — device-MAC unique_id derivation in the flow, device-registry stubs across the flow suites 2026-10-09 07:11:11 +01:00
James Mackay e1a0fc3039 feat(config_flow): the G2 unique_id fix lands — the androidtv entity_id is never a unique_id; MAC derived from the device registry mac connection (empty placeholders skipped), else no unique_id; rename-stability + absence pinned by test (t29 G2) 2026-10-09 07:11:03 +01:00
James Mackay 086c089cc1 fix: commit G3a wiring lost in the amend cycle — listener removal, update-only host path, reauth via the current API (stub surface includes async_update_reload_and_abort) 2026-10-09 07:03:33 +01:00
James Mackay bcb8b962e7 fix: the deprecated listener+reload combo is gone — reauth uses async_update_reload_and_abort(_get_reauth_entry()), zeroconf host update is update-only, the __init__ options-reload listener is removed (grep-pinned); host-change tests assert update-without-reload (t29 G3a) 2026-10-09 07:02:21 +01:00
James Mackay 3e0208789a feat: adb_command is a conforming action — always registered in async_setup (option gates USE), services.yaml with required command/entity, strings services+exceptions, ServiceValidationError on empty/unknown-target/ADB-down; participant registry stays (t29 G8) 2026-10-09 06:59:44 +01:00
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
James Mackay f829ef7a40 docs: README — drop mirror-job references after .gitea workflow rework
Release / release (push) Successful in 5s
2026-10-08 07:32:22 +01:00
James Mackay b706c031ea ci: streamline Gitea release workflow under .gitea/ (GITEA_TOKEN, concurrency, no HACS mirror job) 2026-10-08 07:32:08 +01:00
Levi 304e6dc3ec docs: rewrite README for the Shield-only fork
Release / gitea-release (push) Canceled after 0s
Release / hacs-github-mirror (push) Canceled after 0s
2026-10-08 07:26:13 +01:00
Levi 6622cc04b4 chore: adopt date-based versioning; point manifest metadata at the Gitea repo 2026-10-08 07:26:13 +01:00
Levi 9da7cb8b22 ci: release on every push to master with date-based versioning (vYYYY.MM.DD) 2026-10-08 07:26:13 +01:00
Levi 37df5732b7 ci: port release workflow to Gitea Actions (tag + release zip, optional HACS GitHub mirror) 2026-10-08 07:20:03 +01:00
Levi afc5f0a1d0 docs: add MVP plan and Shield firmware research findings 2026-10-08 07:18:27 +01:00
53 changed files with 10755 additions and 494 deletions

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
# Publish a date-based release every time master moves.
#
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
# manifest to force a specific release, e.g. after a meaningful change); otherwise
# the version is today's date (UTC), so a plain push to master publishes the
# day's release. Repeated pushes on the same day are deduped by tag existence.
#
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
# nvidia_shield/) so the integration is installable manually from any release.
#
# No hand-made secrets are required: Gitea mints a token for every job and
# exposes it as secrets.GITEA_TOKEN.
name: Release
on:
push:
branches: [master]
# Manual runs, for the first release (no tags yet) or to re-publish after a
# partial failure.
workflow_dispatch:
# One release at a time. Two pushes in quick succession would otherwise race to
# tag the same commit; a later run dedupes safely instead of interleaving.
concurrency:
group: release-master
cancel-in-progress: false
permissions:
# The job token pushes the tag and creates the release.
contents: write
env:
ZIP_NAME: nvidia_shield.zip
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Tags drive versioning decisions, so the clone must carry them.
fetch-depth: 0
ref: master
token: ${{ secrets.GITEA_TOKEN }}
- name: Determine release version
id: version
run: |
set -euo pipefail
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
DATE_VERSION="$(date -u +%Y.%m.%d)"
# An unreleased manifest version wins over today's date.
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
VERSION="${DATE_VERSION}"
else
VERSION="${MANIFEST_VERSION}"
fi
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "v${VERSION} is already tagged — nothing to release"
echo "exists=yes" >> "$GITHUB_OUTPUT"
else
echo "releasing v${VERSION}"
echo "exists=no" >> "$GITHUB_OUTPUT"
fi
- name: Create tag
if: steps.version.outputs.exists == 'no'
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "actions@notexpectedyet.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
- name: Build the release zip
if: steps.version.outputs.exists == 'no'
run: |
set -euo pipefail
cd custom_components
zip -r "../${ZIP_NAME}" nvidia_shield
- name: Publish the release
if: steps.version.outputs.exists == 'no'
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Notes: subject lines of the commits in the released range.
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="${TAG}"
fi
{
echo "Release ${TAG}"
echo
git log --pretty=format:'- %s' "$RANGE"
} > /tmp/notes.md
# The body is built by an encoder rather than by string interpolation:
# release notes are multi-line markdown and quoting them into JSON by
# hand is a reliable way to publish a malformed release.
python3 - <<'PY' > /tmp/release.json
import json, os
with open('/tmp/notes.md', encoding='utf-8') as handle:
body = handle.read()
print(json.dumps({
'tag_name': 'v' + os.environ['VERSION'],
'name': 'v' + os.environ['VERSION'],
'body': body,
'draft': False,
'prerelease': False,
}))
PY
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/release.json)"
RELEASE_ID="$(jq -r '.id // empty' /tmp/release-response.json)"
# 409 is "a release for this tag already exists": resolve the existing
# release instead of failing, so the zip still gets attached.
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(curl -sS "${SERVER}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
-H "Authorization: token ${TOKEN}" | jq -r '.id')"
else
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
echo "release creation failed (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
fi
fi
case "$RELEASE_ID" in
''|*[!0-9]*)
echo "could not resolve a release id for ${TAG} (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
;;
esac
echo "release id: ${RELEASE_ID}"
curl -sS -o /tmp/asset-response.json \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${ZIP_NAME}"
echo "published ${TAG} with ${ZIP_NAME}"
+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
-51
View File
@@ -1,51 +0,0 @@
name: Release
on:
push:
branches: [main]
paths:
- custom_components/nvidia_shield/manifest.json
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version from manifest.json
id: version
run: |
VERSION=$(jq -r .version custom_components/nvidia_shield/manifest.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if tag already exists
id: check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.tag }}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Create tag
if: steps.check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
- name: Create GitHub Release
if: steps.check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.version.outputs.tag }}" \
--title "${{ steps.version.outputs.tag }}" \
--generate-notes
+163 -53
View File
@@ -1,101 +1,211 @@
# NVIDIA Shield # NVIDIA Shield
[![hacs][hacs-badge]][hacs-url] [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Home Assistant integration for the NVIDIA Shield TV — ADB media session tracking for
now-playing state from any app, plus Google remote-protocol commands, Jellyfin rich
metadata, and power monitoring.
Enhanced NVIDIA Shield TV integration for Home Assistant. ADB-based media session tracking, Jellyfin rich metadata, power monitoring, and receiver coordination. This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/ltomes/ha-nvidia-shield)
(early 0.1.x), rebuilt around a different architecture: one integration, one device,
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
integrations.
> **Status:** Early access (0.1.x). Tested on the author's own setup; expect rough edges and occasional breaking changes until 1.0. Please file issues on GitHub if anything is broken. > **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
> Expect rough edges until the MVP phases land. The build-out plan is in
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
```mermaid ## What we're building (the fork's goals)
graph TD
subgraph "NVIDIA Shield Integration"
ADB["ADB Media Session"] -->|now playing| Entity["media_player.nvidia_shield"]
JF["Jellyfin"] -->|rich metadata| Entity
ATV["Android TV Remote"] -->|power / commands| Entity
PWR["Smart Plug"] -->|power state| Entity
end
Entity -->|"media_dag_*"| MZ["Media Zone"] The fork exists to make a single integration own the Shield end-to-end:
style Entity fill:#76b900,color:#fff 1. **Event-driven ADB media session tracking as the primary playback state source** —
style MZ fill:#1a73e8,color:#fff `dumpsys media_session` over a persistent connection, not screen capture, not
style ADB fill:#444,color:#fff 10-second polling.
style JF fill:#00a4dc,color:#fff 2. **Fallback state for apps without media sessions** — e.g. TiviMate publishes
style ATV fill:#444,color:#fff none at all; foreground-app detection (`mResumedActivity`) + last-known-state
style PWR fill:#444,color:#fff + the "audio playback" hint from the same session dump close that gap.
3. **All-in-one device** — absorb the Google remote protocol (`androidtvremote2`):
a `remote` entity alongside the media player, `play_media` with
`media_content_type: channel` for TiviMate channel-by-number, absolute volume,
focused-app push events. ADB stays the playback-state source; ADB pairing stays intact.
4. **Retire the surrounding glue** — the `mediasession2mqtt` bridge, universal
media player templates, and external remote entity reading all become native
integration behavior.
Phases 0–4 with files, risks, and definitions of done live in
[`docs/MVP_PLAN.md`](docs/MVP_PLAN.md).
## Current features (inherited from upstream 0.1.x)
- **ADB Media Session** — now-playing from any app that publishes a session
(Spotify, Netflix, Plex, Wholphin, …)
- **Jellyfin rich metadata** — title, series/episode, position, stream details
(resolution, codec, HDR, audio format) via the HA Jellyfin integration's cache
- **Power monitoring** — smart plug for reliable on/off with auto-discovered
sibling sensors (watts, voltage, current)
- **Intent tracking** — commands reflect in HA state immediately, self-heal
against actual device state
- **Privacy mode** — show only app name without media details for non-Jellyfin apps
- **Media DAG attributes** — `media_dag_*` attributes for media-zone style orchestration
Changes inherited from upstream still apply in 0.1.x code: polling is timer-based,
the ADB connection lives inside the media player entity, and fallback app state
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`.
### YAML seeding (optional, seed-only)
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
interactive setup flow — nothing is created without the user walking the
standard flow, and pairing stays interactive exactly as with UI setup:
```yaml
nvidia_shield:
- name: Living Room
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
adb_port: 5555
``` ```
## Features `protocol_host` is a prefill hint only (the PIN pairing still happens in
the flow). Blocks without protocol/adb hosts are ignored with a warning.
Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live
in the storage registry and require user consent to remove. Deleting/re-
adding is the only way to move an imported entry between hosts; the UI is
the contractual path (YAML is the seed, the flow is the truth).
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin ### Raw `adb_command` service (opt-in)
- **Jellyfin Rich Metadata** -- Title, series, season/episode, duration, position, stream details (resolution, codec, HDR, audio format)
- **Power Monitoring** -- Smart plug for reliable on/off detection with auto-discovered sibling sensors (watts, voltage, current) When enabled in the integration's options (`adb_command_enabled`, **default
- **Receiver Coordination** -- Links to a receiver entity and input name off**), the integration registers the `nvidia_shield.adb_command` service:
- **Intent Tracking** -- Commands reflect in HA state immediately, self-heal against actual device state send a raw ADB shell command to the entry's persistent connection
- **Privacy Mode** -- Show only app name without media details for non-Jellyfin apps (`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity entity is optional when exactly one entry provides the service). This is the
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration 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
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
run the release workflow (`.github/workflows/release.yml`):
- A plain push publishes the current day's release (same-day pushes are deduped).
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
specific version's release before the date fallback kicks in.
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
installs. (No automatic mirroring — see the HACS note below.)
## Installation ## Installation
### HACS (recommended) ### HACS
HACS only works with public GitHub repositories, so the Gitea repo cannot be
added to HACS directly. If you set up a public GitHub mirror of the releases,
add it as a custom repository:
1. In HACS, open the overflow menu (⋮) → **Custom repositories**. 1. In HACS, open the overflow menu (⋮) → **Custom repositories**.
2. Paste `https://github.com/ltomes/ha-nvidia-shield`, pick type **Integration**, click **Add**. 2. Paste `https://github.com/notexpectedyet/ha-nvidia-shield`, pick type
**Integration**, click **Add**.
3. Find **NVIDIA Shield** in the HACS list and click **Download**. 3. Find **NVIDIA Shield** in the HACS list and click **Download**.
4. **Restart Home Assistant.** 4. **Restart Home Assistant.**
5. Go to **Settings → Devices & Services → Add Integration**, search for **NVIDIA Shield**, and set up the integration (see [Configuration](#configuration)).
### Manual ### Manual
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/` directory, restart, then add the integration from **Settings → Devices & Services**. Copy `custom_components/nvidia_shield` to your HA `config/custom_components/`
directory, restart, then add the integration from **Settings → Devices & Services**.
## Configuration ## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield" 1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. Select the Android TV Remote entity for your Shield 2. Select the Android TV Remote entity for your Shield
3. Enable ADB if you want now-playing from all apps 3. Enable ADB if you want now-playing from all apps
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield 4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
5. Optionally configure Jellyfin, receiver, power switch 5. Optionally configure Jellyfin, receiver, power switch
### ADB Pairing ### ADB Pairing
ADB is paired during setup via a dedicated config flow step. If you need to re-pair later (e.g., after factory reset), use the **"Pair ADB"** button on the device page in HA. ADB is paired during setup via a dedicated config flow step. If you need to
re-pair later (e.g. after factory reset), use the **"Pair ADB"** button on the
device page in HA.
Requirements: Settings > Developer options > Network debugging enabled on the Shield. Requirements: Settings > Developer options > Network debugging enabled on the Shield.
### Options ### Options
- **Jellyfin Media Player** -- Auto-detected or manually selected - **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** -- For Media Zone coordination - **Receiver + Input** — for media-zone coordination
- **Power Switch** -- Smart plug entity - **Power Switch** — smart plug entity
- **ADB Host/Port** -- Override auto-detection if needed - **ADB Host/Port** — override auto-detection if needed
## Entities ## Entities
| Entity | Type | Description | | Entity | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata | | Media Player | `media_player` | Main entity with playback controls and metadata |
| Current App | `sensor` | Currently running app | | Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now Playing | `sensor` | Formatted now-playing string | | Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
| Pair ADB | `button` | Initiate or re-pair ADB connection | | Pair ADB | `button` | Initiate or re-pair ADB connection |
## Development ## Development
Shared ADB helpers live in [ha-adb-common](https://github.com/ltomes/ha-adb-common) and are also used by [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv). For HACS installs, a copy is **vendored** at `custom_components/nvidia_shield/ha_adb_common/` so end users need nothing extra. We plan to publish `ha-adb-common` to PyPI so it can be declared as a normal `manifest.json` requirement; until then, the vendored copy is the source consumed at runtime. - **Repository:** [Gitea — git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield), branch `master`
- **CI/CD:** Gitea Actions; every push to `master` releases (see
To hack on the shared code locally, clone [ha-adb-common](https://github.com/ltomes/ha-adb-common) as a sibling directory and run `uv sync --extra dev` — the `[tool.uv.sources]` section in `pyproject.toml` wires it up editable. `.gitea/workflows/release.yml`). Auth uses the job-provided `GITEA_TOKEN` —
no extra secrets required.
## Part of the Media Zone Ecosystem - **Shared ADB helpers** are vendored at `custom_components/nvidia_shield/ha_adb_common/`
(trimmed in this fork); dev extras in `pyproject.toml` (`uv sync --extra dev`).
This integration works standalone, but pairs with [ha-media-zone](https://github.com/ltomes/ha-media-zone) to create a unified zone controller:
- [ha-media-zone](https://github.com/ltomes/ha-media-zone) -- Orchestrates receiver + display + sources into one entity
- [ha-emotiva-mc1](https://github.com/ltomes/ha-emotiva-mc1) -- RS232 control for Emotiva MC1 receiver
- [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv) -- Hisense Google TV control via ADB + AirPlay
[hacs-badge]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg
[hacs-url]: https://github.com/hacs/integration
## License ## License
+210 -8
View File
@@ -1,16 +1,150 @@
"""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 import config_entries
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_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN,
SERVICE_ADB_COMMAND,
)
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 async_setup(hass: HomeAssistant, config) -> bool:
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
interactive SOURCE_IMPORT config flow — nothing is created without the
user walking the flow, and pairing stays interactive exactly as for UI
setup. Removing the YAML block creates OR deletes nothing. UI-configured
entries are never re-seeded (host/MAC duplicate guards).
"""
domain_config = (config or {}).get(DOMAIN)
for block in _yaml_seed_blocks(domain_config or []):
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST):
_LOGGER.warning(
"nvidia_shield YAML block without protocol/adb host ignored: %s",
block.get("name", "<unnamed>"),
)
continue
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=block,
)
)
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
hass.services.async_register(
DOMAIN,
SERVICE_ADB_COMMAND,
_async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA,
)
return True
def _yaml_seed_blocks(domain_config) -> list[dict]:
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
blocks: list[dict] = []
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
for block in raw_list:
if not isinstance(block, dict):
continue
name = block.get("name") or DEFAULT_NAME
# t32 F4 (review rider): the seed honors the block's shape — a
# protocol-only block does NOT force ADB on; ADB comes only when the
# block carries an adb host (interactive cosmetics stay in the flow).
seed = {
"name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
# protocol fields are prefill hints ONLY — pairing stays interactive
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
}
if block.get("unique_id"):
seed["unique_id"] = str(block["unique_id"])
blocks.append(seed)
return blocks
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
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
# host/port resolve from entry.options AT CALL TIME, so the poller built
# after a zeroconf IP change targets the NEW address without a reload
# (the setup-time closure pinned the old address). A key-path change
# still needs the Pair ADB button / reconfigure reload, by design.
def _factory():
adb_now = resolve_adb_config(hass, entry)
if adb_now is None:
return None
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
return _factory
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -18,10 +152,77 @@ 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)) 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 return True
@@ -30,11 +231,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
+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)
+11 -24
View File
@@ -5,8 +5,7 @@ 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, EntityCategory
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .adb_session import AdbMediaSessionPoller, ensure_adb_key
@@ -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__)
@@ -36,6 +36,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = "Pair ADB" _attr_name = "Pair ADB"
_attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:link-variant" _attr_icon = "mdi:link-variant"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -52,29 +53,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 +68,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():
+517 -88
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 device_registry as dr
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,
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__)
@@ -39,6 +52,34 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
return list(input_map.keys()) if isinstance(input_map, dict) else [] return list(input_map.keys()) if isinstance(input_map, dict) else []
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
"""A stable unique_id from the atv entity's device MAC (t29 G2).
The device registry holds CONNECTION_NETWORK_MAC for Android TV
entities configured with a host — far stronger than the renameable
entity_id. None (→ no unique_id at all, not an entity-derived
placeholder) when the entity is unregistered or carries no MAC.
"""
if not androidtv_entity:
return None
registry = er.async_get(hass)
entity = registry.entities.get(androidtv_entity)
device_id = getattr(entity, "device_id", None) if entity else None
device = (
dr.async_get(hass).async_get(device_id) if device_id else None
)
if device is None:
return None
if isinstance(dr, dict): # stub shape guard
return None
for connection in device.connections:
# connections are {connection_type, value} pairs; only a REAL mac
# value is usable (an empty placeholder is skipped)
if connection[0] == "mac" and connection[1]:
return connection[1]
return None
def _find_jellyfin_entity(hass, device_name: str) -> str | None: def _find_jellyfin_entity(hass, device_name: str) -> str | None:
"""Auto-detect the Jellyfin media_player entity for a device.""" """Auto-detect the Jellyfin media_player entity for a device."""
registry = er.async_get(hass) registry = er.async_get(hass)
@@ -69,6 +110,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)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
if "host" in defaults:
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
return schema_dict
def _build_selectors(): def _build_selectors():
"""Build reusable selector instances.""" """Build reusable selector instances."""
return { return {
@@ -90,26 +206,66 @@ 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)
await self.async_set_unique_id(androidtv_entity) has_source = (
self._abort_if_unique_id_configured() 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,
)
# t29 G2: the androidtv entity_id is a renameable reference —
# never a unique_id. A sqlite/registry MAC is preferred (the
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
# resolvable MAC the entry gets NO unique_id (the same
# uniqueness-less semantics _async_handle_discovery_without_
# unique_id applies to MAC-less discovery) — renames can never
# break uniqueness.
fallback_unique_id = _unique_id_from_atv_device(
self.hass, androidtv_entity
)
if fallback_unique_id:
await self.async_set_unique_id(fallback_unique_id)
self._abort_if_unique_id_configured()
# Auto-detect Jellyfin if not manually specified # Auto-detect Jellyfin if not manually specified
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 +279,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 +288,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 +318,84 @@ 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:
# t29 G3a: the deprecated update+schedule-reload combo is
# gone (2026.12 error) — the entry updates ONLY here; the
# self-heal rides on the t32 F1 poller factory, which
# re-reads entry.options at call time (no reload).
self.hass.config_entries.async_update_entry(
entry, options=options
)
return self.async_abort(reason="already_configured")
self._abort_if_unique_id_configured()
else:
# t32 F2 (review rider): MAC-less discovery cannot carry a
# unique_id — HA's helper records the uniqueness-less discovery
# (the same discovery context can't stack duplicates) and the
# host guard keeps a second discovery of an already-configured
# host from stacking another prefill.
self._async_handle_discovery_without_unique_id()
for entry in self._async_current_entries():
existing_host = entry.options.get(
CONF_PROTOCOL_HOST
) or entry.options.get(CONF_ADB_HOST)
if existing_host and existing_host == host:
return self.async_abort(reason="already_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 +405,185 @@ 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_import(self, data):
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
The import validates the block and routes into the standard user
flow — pairing stays interactive, exactly one visit. Duplicate
guards: the block's host/MAC-virtual unique_id aborts when an
entry already holds it, and a UI-configured entry with the same
protocol/adb host is never re-seeded.
"""
block = dict(data or {})
name = block.get("name") or DEFAULT_NAME
seed_unique_id = block.get("unique_id")
if not seed_unique_id:
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
# duplicate guards
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
for existing in self._async_current_entries():
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
CONF_ADB_HOST
)
if existing.unique_id == seed_unique_id or (
yhost and ehost == yhost
):
_LOGGER.info(
"nvidia_shield YAML seed for %r matches an existing "
"entry — not re-seeded",
name,
)
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(seed_unique_id)
self._abort_if_unique_id_configured()
# route into the standard user flow (single interactive visit);
# t32 F4: the prefill honors the block shape — ADB only when the
# block actually carries an adb host.
self._user_data = {
"name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
# protocol prefill hints ONLY (pairing stays interactive)
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
}
self._discovered_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
CONF_ADB_HOST
]
return await self.async_step_user(None)
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 + reload + abort via the current API (G3a).
The deprecated update-entry + schedule-reload boomerang listener is
gone (2026.12 treats it as an error); the current config-entries
contract is async_update_reload_and_abort with _get_reauth_entry.
"""
entry = self._reauth_entry or self._get_reauth_entry()
self.async_update_reload_and_abort(
entry,
options_updates={
**entry.options,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
},
)
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 +600,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"
@@ -238,42 +617,102 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry): def async_get_options_flow(config_entry):
return NvidiaShieldOptionsFlow(config_entry) return NvidiaShieldOptionsFlow()
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow): class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Handle options for Nvidia Shield — single unified page.""" """Handle options for Nvidia Shield — single unified page (t29 G3b).
def __init__(self, config_entry): The constructor takes NO arguments: HA assigns ``self.config_entry``
self._config_entry = config_entry on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
self._options = dict(config_entry.options) deprecated). Suggested values come through
``add_suggested_values_to_schema``, and saving reloads via the
OptionsFlowWithReload base — no listener boomerangs.
"""
def __init__(self):
self._options: dict | None = None
@property
def _options_dict(self) -> dict:
if self._options is None:
if self.config_entry is not None:
self._options = dict(self.config_entry.options)
else:
self._options = {}
return self._options
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:
self._options.update(user_input) # P5-2: validate the digit delay (0.05–1.0 s, §7 window)
return self.async_create_entry(title="", data=self._options) 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_dict.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_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
self._options_dict.update(user_input)
self._options_dict[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_dict[CONF_APP_ICON_MAP] = icons
return self.async_create_entry(title="", data=self._options_dict)
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).
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
# Jellyfin # Jellyfin
jf = self._options.get(CONF_JELLYFIN_ENTITY) jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
if jf: if jf:
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf) jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
schema_dict[jf_key] = sel["media_player"] schema_dict[jf_key] = sel["media_player"]
# Receiver # Receiver
recv = self._options.get(CONF_RECEIVER_ENTITY) recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
recv_key = vol.Optional(CONF_RECEIVER_ENTITY) recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
if recv: if recv:
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv) recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
schema_dict[recv_key] = sel["receiver"] schema_dict[recv_key] = sel["receiver"]
# P5-2: channel digit behavior (the user-validated §7 flow)
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
# P5-3: the live events push is opt-in (default off; the user
# tests stability on their device and reports)
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
# P4-2a: the raw adb_command service is opt-in (default off)
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = 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_dict.get(CONF_RECEIVER_INPUT, "")
receiver = self._options.get(CONF_RECEIVER_ENTITY) receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
inputs = _get_receiver_inputs(self.hass, receiver) inputs = _get_receiver_inputs(self.hass, receiver)
if inputs: if inputs:
@@ -286,42 +725,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
) )
input_key = vol.Optional(CONF_RECEIVER_INPUT) input_key = vol.Optional(CONF_RECEIVER_INPUT)
if current_input: if current_input:
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input) input_key = vol.Optional(CONF_RECEIVER_INPUT)
schema_dict[input_key] = input_sel schema_dict[input_key] = input_sel
else: else:
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
CONF_RECEIVER_INPUT, default=current_input
)] = str
# Power # Power
pwr = self._options.get(CONF_POWER_ENTITY) pwr = self._options_dict.get(CONF_POWER_ENTITY)
pwr_key = vol.Optional(CONF_POWER_ENTITY) pwr_key = vol.Optional(CONF_POWER_ENTITY)
if pwr: if pwr:
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr) pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"] schema_dict[pwr_key] = sel["power"]
# ADB options # ADB options
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
CONF_ADB_ENABLED, schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
default=self._options.get(CONF_ADB_ENABLED, False),
)] = bool
schema_dict[vol.Optional(
CONF_ADB_STORE_APP_INFO,
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
)] = bool
# ADB host/port (optional overrides, shown when ADB is enabled) # ADB host/port (optional overrides, shown when ADB is enabled)
adb_host = self._options.get(CONF_ADB_HOST, "") schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional( schema_dict[vol.Optional(CONF_ADB_PORT)] = int
CONF_ADB_HOST,
default=adb_host,
)] = str
schema_dict[vol.Optional(
CONF_ADB_PORT,
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
)] = int
# t29 G3b: suggested values come from the entry options via the
# documented helper — no per-key `default=` in the schema build.
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema_dict), data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), self._options_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,101 @@
"""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**"
# G14: the documented explicit redaction field list — the audit calls out
# config fields like the hosts; pairing/key-shaped fields are masked by
# async_redact_data and the marker scan adds a second layer.
_TO_REDACT = {
"pairing_code",
"adb_pairing_code",
"protocol_pairing_code",
"serial_number",
"uuid",
"adb_host",
"protocol_host",
"host",
}
def _redact(obj):
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
"""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,
},
}
# G14 (t30): the documented helper is the primary redaction path with
# an explicit field list; the marker scan above runs defensively after.
try:
from homeassistant.util.diagnostic import async_redact_data
except ImportError:
async_redact_data = None
if async_redact_data is not None:
payload = async_redact_data(payload, _TO_REDACT)
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,18 +136,15 @@ 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)
except Exception as err: except Exception as err:
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+23 -14
View File
@@ -1,16 +1,25 @@
{ {
"domain": "nvidia_shield", "domain": "nvidia_shield",
"name": "NVIDIA Shield", "name": "NVIDIA Shield",
"codeowners": [ "codeowners": [
"@ltomes" "@notexpectedyet"
], ],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://github.com/ltomes/ha-nvidia-shield", "documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
"iot_class": "local_polling", "iot_class": "local_push",
"issue_tracker": "https://github.com/ltomes/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": "0.1.1" ],
"version": "2026.10.08",
"zeroconf": [
"_androidtvremote2._tcp.local."
],
"integration_type": "device",
"loggers": [
"adb_shell",
"androidtvremote2"
]
} }
+581 -181
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.exceptions import ServiceValidationError
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,
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:
@@ -136,6 +216,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
# G10 (t30): when the protocol transport is live and captured, the
# registry card carries the protocol-pushed identity (name from the
# device itself, manufacturer 'Google' per the audit ruling); the
# pre-capture shape stays the entry-title fallback.
captured = (
self._manager.protocol_device_data if self._manager is not None else None
)
if captured:
name, manufacturer, model, sw = captured
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=name or self._entry.title,
manufacturer=manufacturer,
model=model,
sw_version=sw or None,
)
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title, name=self._entry.title,
@@ -147,22 +243,34 @@ 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
# 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 +288,16 @@ 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/t29 G8: drop this entity's service participation (same key
# as add). The service itself stays registered (always-on setup).
# ── Intent management ── # ── Intent management ──
@@ -243,9 +356,29 @@ 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 (
self._cmd(intent.command) 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._intent_timer_unsub = async_call_later( self._intent_timer_unsub = async_call_later(
self.hass, INTENT_TIMEOUT, self._on_intent_timeout self.hass, INTENT_TIMEOUT, self._on_intent_timeout
) )
@@ -268,11 +401,14 @@ 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
_LOGGER.debug("Intent cleared: device powered off") and not self._is_power_on()
self._clear_intent() and self._intent
):
_LOGGER.debug("Intent cleared: device powered off")
self._clear_intent()
# Jellyfin state change — check for intent confirmation and fetch media info # Jellyfin state change — check for intent confirmation and fetch media info
if entity_id == self._jellyfin_entity and new_state_obj: if entity_id == self._jellyfin_entity and new_state_obj:
@@ -296,7 +432,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 +449,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()) self.async_write_ha_state()
async def _adb_poll(self) -> None: def _track_task(self, coro) -> None:
"""Poll ADB for wake state and media session, then schedule next poll.""" """Dispatch an entity background task; cancelled on unload.
try:
if not self._adb_poller:
return
# Always check wake state Entity-owned tasks (intent retries, media-info fetch) must never go
prev_awake = self._adb_awake through a bare hass.async_create_task: they would survive removal
self._adb_awake = await self._adb_poller.is_awake() (reviewer finding F2). The connection manager owns its own tasks and
the socket — nothing here touches it directly.
# Only poll media session when awake and Jellyfin is NOT active """
jf = self._read(self._jellyfin_entity) task = self.hass.async_create_task(coro)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED) self._entity_tasks.add(task)
task.add_done_callback(self._entity_tasks.discard)
if self._adb_awake: return task
# 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()
except Exception:
_LOGGER.debug("ADB poll error", exc_info=True)
finally:
# Use slow retry if ADB is disconnected, normal interval if connected
if self._adb_enabled:
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL
self._adb_poll_unsub = async_call_later(
self.hass, interval, self._adb_poll_callback
)
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 +482,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 +495,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 +524,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 +545,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,10 +567,9 @@ 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
# ── Metadata from Jellyfin ── # ── Metadata from Jellyfin ──
@@ -516,13 +580,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 +657,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 +716,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 +729,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 +742,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 +796,40 @@ 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 # G12 (t30): attributes that change with every poll must not bloat the
# database (entity doc) — dag internals are declared unrecorded.
_unrecorded_attributes = frozenset(
{
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RUNNING_APPS,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
}
)
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 +871,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 +910,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 (
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
# 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: 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 return
# Fall back to Android TV Remote # 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 +953,183 @@ 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
# G18 (2026-05-20 blog): BrowseMediaSource must carry the domain
# parameter so the frontend can attribute the media tree.
children = [
BrowseMediaSource(
title=name,
media_class=MediaClass.APP,
media_content_type="app",
media_content_id=pkg,
can_play=True,
can_expand=False,
domain=DOMAIN,
)
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,
domain=DOMAIN,
)
# ── 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.Required("entity_id"): str,
}
)
async def _async_adb_command_service(call) -> None:
"""Run a raw ADB shell command on the target connection (t29 G8).
The integration's async_setup registers this ALWAYS; the entry's
adb_command_enabled option gates USE (participants register from
async_added_to_hass). Failures raise ServiceValidationError with
translated keys (action-exceptions) — never warning logs.
"""
command = str(call.data.get("command") or "").strip()
if not command:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="invalid_command"
)
entity_id = call.data.get("entity_id")
manager = _ADB_COMMAND_TARGETS.get(entity_id)
if manager is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_target",
)
poller = manager.adb_poller # public accessor (t21 F6)
if poller is None or not manager.connected:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="adb_unavailable"
)
result = await poller.shell(command)
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
+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)
+51 -15
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,
@@ -38,6 +39,8 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
# G12 (t30): poll-driven attributes never belong in the database
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -60,49 +63,82 @@ class NvidiaShieldSensorBase(SensorEntity):
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield.""" """Current running app on the Shield.
_attr_name = "Current App" 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_translation_key = "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):
"""What's currently playing via Jellyfin.""" """What's currently playing via Jellyfin."""
_attr_name = "Now Playing" _attr_translation_key = "now_playing"
_attr_icon = "mdi:play-circle" _attr_icon = "mdi:play-circle"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -0,0 +1,24 @@
adb_command:
# Both fields required — integrating targeting must not be optional
# (docs/dev_101_services: no default target when none specified).
name: ADB command
description: Send a raw ADB shell command to the persistent connection of
an Nvidia Shield entry (the entry must have the adb_command_enabled
option on; the integration gates USE, not registration).
fields:
command:
name: Command
description: The shell command to run on the device, e.g.
"dumpsys power | head -5".
required: true
example: "dumpsys power | head -5"
selector:
text:
entity_id:
name: Entity
description: The Nvidia Shield media player entity to target.
required: true
selector:
entity:
integration: nvidia_shield
domain: media_player
+88 -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,48 @@
"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.",
"yaml_seed_only": {},
"yaml_seed": {
"title": "YAML succeeded earlier",
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
}
} }
}, },
"options": { "options": {
@@ -54,19 +83,69 @@
"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."
} }
} }
} }
},
"services": {
"adb_command": {
"name": "ADB command",
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device."
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
}
}
}
},
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
},
"adb_unavailable": {
"title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
} }
} }
@@ -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,48 @@
"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.",
"yaml_seed_only": {},
"yaml_seed": {
"title": "YAML succeeded earlier",
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
}
} }
}, },
"options": { "options": {
@@ -54,19 +83,69 @@
"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."
} }
} }
} }
},
"services": {
"adb_command": {
"name": "ADB command",
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device."
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
}
}
}
},
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
},
"adb_unavailable": {
"title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
} }
} }
+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).
+529
View File
@@ -0,0 +1,529 @@
# ha-nvidia-shield MVP Plan (Shield-only fork)
Scope: Shield-only. Primary state = ADB media session tracking (`dumpsys media_session`),
persistent event-driven connection, with a fallback path for apps that don't publish
media sessions. ADB pairing flow stays intact. Companion doc: `docs/RESEARCH_SHIELD.md`
(firmware research results, fixtures, and the user's current YAML glue).
**Decisions locked:**
- Option 1 adopted: absorb the remote protocol (`androidtvremote2`) — all-in-one device.
- TiviMate (`ar.tvplayer.tv`) publishes no media session → headline fallback app.
- `play_media(channel)` replicates core androidtv_remote behavior exactly
(digits, 0.1 s delay, no ENTER).
---
## 1. Current state assessment
### Architecture as found
```
config_flow.py ── user step → adb_pair step (one-shot connect test)
__init__.py ── no runtime object; hass.data[DOMAIN][entry_id] = entry (nothing!)
adb_session.py ── AdbMediaSessionPoller = AdbClientBase + poll() (thin)
media_player.py ── the real "coordinator": entity owns ADB socket, polling loop,
intent tracking, state tiering, Jellyfin/media-info, power logic
sensor.py ── 2 read-only mirror sensors
button.py ── Pair ADB (throwaway poller, connect→disconnect→reload)
jellyfin.py ── piggybacks on HA core Jellyfin coordinator cache (zero extra HTTP)
power.py ── sibling-sensor discovery for the smart plug
ha_adb_common/ ── vendored: adb_client (connect/shell/keyevents/media_session poll),
media_session parser, running_apps, intent dataclass, keys manager
```
There is **no DataUpdateCoordinator and no shared runtime object**. The entity
(`NvidiaShieldMediaPlayer`) *is* the coordinator: it creates the poller in
`async_added_to_hass`, runs `async_call_later` polling, and writes state itself.
### What already works (keep)
| Area | File | Notes |
|---|---|---|
| ADB key lifecycle: generate, migrate from legacy paths, namespace per domain | `ha_adb_common/keys.py` | Solid; pairing-flow compatible. Keep untouched. |
| Pairing UX: config-flow step + Pair ADB button + "approve on Shield" guidance | `config_flow.py`, `button.py` | Works; keep intact per constraint. |
| ADB connect via `adb_shell` (`AdbDeviceTcpAsync` + RSA signer in executor) | `adb_client.py` | Correct executor usage; blocking I/O off the loop. |
| Media session parsing: package/active/state/metadata from `dumpsys media_session`; foreground-app preference; playing>paused ordering | `media_session.py` | Works on Shield Android 11; version-drift risk (see fragility). |
| Optimistic UI: `PlaybackIntent` + self-heal + one retry + external-override detection | `intent.py`, `media_player.py` | Good design; survives rewrite. |
| State tiering: power → intent → Jellyfin → ADB session → ON | `media_player.py` `state` property | Sound priority model. |
| Jellyfin metadata piggyback (resolution/HDR/audio labels from core coordinator cache) | `jellyfin.py` | Zero-cost; keep. |
| Power: ADB wake as authority, plug "off" as hard off, sibling sensor discovery | `power.py`, `media_player.py` | Keep. |
| Running apps from `dumpsys activity recents` | `adb_client.py`, `running_apps.py` | Useful for Media DAG switcher. |
| Media commands: ADB keyevent when connected & Jellyfin inactive, else androidtv_remote | `media_player.py` `_media_cmd` | Sensible dual path; superseded by protocol-first routing in Phase 2. |
### What is fragile
1. **Connection ownership is in the entity.** The ADB socket, its poll timer and all
state live on `NvidiaShieldMediaPlayer`. Reload (options change triggers full
entry reload via `_async_update_listener`) tears down and re-handshakes the ADB
connection. Sensors have no access to ADB data. A second entity is impossible.
2. **Untracked in-flight poll tasks.** `_adb_poll` is dispatched via
`async_create_task` and never cancelled on unload; an in-flight `shell()` after
`disconnect()` hits `shell()`'s auto-reconnect → surprise reconnect after removal.
3. **Silent failure mode.** `shell()` swallows errors at DEBUG, returns `None`, marks
disconnected, and the next poll is 300 s later. Meanwhile `self._adb_awake` and
`last_info` keep their last values — the entity can report `playing` for minutes
while the device is asleep/dead. **No staleness/expiry on media session info.**
4. **Wake detection is a substring.** `"Awake" in dumpsys power result` — works on
this firmware (`mWakefulness=Awake/Asleep`, verified R5) but fragile against
future values like `Dozing`; switch to exact value match.
5. **Parser depends on exact dumpsys text** (`package=`, `active=true`,
`state=PlaybackState` `state=3/2`, `description=` positional split) and `head -200`
truncates the dump — sessions below the cut are lost. No position parsed from
`PlaybackState` despite it being present (verified R9: position/ms + uptime-ms
`updated`).
6. **Intent retry sends the wrong path.** `_on_intent_timeout` retries via
`_cmd()` (androidtv_remote) even when the original command succeeded via ADB.
7. **Dead/duplicated logic.** `jf_active` computed and unused in `_adb_poll`;
`_resolve_adb_host` duplicated in 3 places; `JELLYFIN_APP_IDS` defined but never
used (Wholphin routing); Current App sensor merely mirrors androidtv attributes.
8. **State writes gated on change.** `_adb_poll` writes HA state only when wake state
flipped or `last_info` exists — `running_apps` changes and info *clears* can be
missed; stale attribute windows.
9. **`power is False → IDLE` not OFF.** ADB-asleep maps to IDLE (standby) by design,
but the plug-off check happens before ADB, so semantics are split across two
places and easy to break.
10. **No tests, no CI lint gate** (ruff/pytest configured in `pyproject.toml` but
zero test files). A rewrite will be blind.
11. `iot_class: local_polling` + per-entity 10 s polling of **three** dumpsys
commands (power, media_session, activity recents) — wasted round trips when
nothing changed.
### What's missing for MVP
- Persistent connection manager with watchdog + backoff (nothing tracks connection
health; the only "watchdog" is the next poll).
- Event-driven trigger source (foreground-app change events) — today everything is
timer-driven.
- Fallback foreground/state path for apps without media sessions (TiviMate is the
headline case — verified no session, R2).
- Playback position from media sessions (+ uptime-anchored math, R9).
- Media session staleness handling (session gone → transition to idle).
- Tests + CI (lint + unit) to make the rewrite safe.
---
## 2. Event-driven rewrite: keep or replace the polling layer?
**Decision: replace the per-entity poll loop with an entry-scoped
`ShieldConnectionManager` that runs a hybrid event/heartbeat loop. Do not try to
make logcat a true push stream over `adb_shell` for MVP.**
### Why not pure logcat push (yet)
CORRECTION (C-STREAM, spike-verified on our pinned range): `adb_shell` 0.4.4
**does** ship `streaming_shell` — a generator yielding each line of output — on
both the sync `AdbDevice` and async `AdbDeviceAsync` (async needs the tiny
`aiofiles` + `async_timeout` extras, both already HA-ecosystem staples). So a
true logcat push stream needs neither internals hacks nor a new major
dependency. What keeps the hybrid design honest is *device behavior*, not the
library: logcat does not reliably carry *playback state* for third-party apps —
it reliably carries **foreground/app lifecycle** events (verified R8:
`ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` on every app open,
including home-button → launcher). Streaming stability over multi-hour
connections still needs a device spike before we make it the sole trigger.
### The hybrid design (target architecture)
```
entry.runtime_data = ShieldConnectionManager
├─ transport A: androidtvremote2 protocol client (Phase 2)
│ push callbacks: is_on, is_available, current_app, volume_info
│ commands: send_key_command, send_launch_app_command, IME
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
├─ trigger listener: foreground-change events
├─ trigger listener: foreground-change events
│ Phase 1: logcat delta snapshots ~2–5 s cadence (safe default)
│ Phase 2+: streaming_shell logcat stream on a dedicated connection
│ (library support verified on 0.4.4; pending device stability spike)
├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
│ → if changed → `dumpsys media_session` fetch
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
├─ parsed StateStore: {is_on, foreground_pkg, sessions[], audio_playback[],
│ last_updated ts per field}
└─ event API: listener registration so entities are thin views
```
Net effect vs today: one persistent socket instead of reconnect-on-failure;
wake-ups driven by device events within seconds; heartbeat as fallback; wake check
folded into the foreground query instead of a separate grep; per-cycle cost drops
from 3 dumpsys commands × every 10 s to ~1 command × on-event + 1 × per heartbeat.
### Blocking issues to fix before/during the rewrite (flagged)
| # | Blocker | Where | Fix |
|---|---|---|---|
| B1 | Connection owned by entity; no shared runtime object | `media_player.py` `_setup_adb`, `__init__.py` | Move connection+state into `ShieldConnectionManager` stored in `entry.runtime_data`; entities subscribe. Mirror HA core androidtv's `entry.runtime_data` dataclass pattern. |
| B2 | Untracked `async_create_task` polls escape unload | `media_player.py` `_adb_poll_callback` | Manager owns its tasks; cancel on `async_stop` + `EVENT_HOMEASSISTANT_STOP`; guard `shell()` reconnect behind explicit lifecycle calls. |
| B3 | `shell()` auto-reconnect hides failure from callers | `adb_client.py` `shell` | Explicit error propagation; reconnect handled only by watchdog. Adopt androidtv core's `adb_decorator` shape: catch known ADB exceptions → log once → close → mark unavailable. |
| B4 | `head -200` truncation + parser text-coupling | `adb_client.py`, `media_session.py` | Remove `head`; tolerant parser w/ real firmware fixtures (captured — `docs/RESEARCH_SHIELD.md`). |
| B5 | Stale-state window (no TTL on `last_info`/`_adb_awake`) | `media_player.py` | StateStore carries timestamps; info older than TTL (e.g. 2×heartbeat) is expired. |
| B6 | Options change reloads entry → full reconnect churn | `__init__.py` update listener | Adopt core androidtv's pattern: only reload for structural options (`RELOAD_OPTIONS`), otherwise re-read options via `async_dispatcher_send`. |
---
## 3. Fallback mechanism for apps without media sessions
Options considered:
| Option | Reliability | Cost | Notes |
|---|---|---|---|
| **A. Foreground app via `dumpsys activity activities` (`mResumedActivity` / `topResumedActivity`)** | High on Shield (Android 11/12 line) | 1 cheap command, on-event only | Deterministic current app; works for *every* app. Verified on this Shield (R4): `mResumedActivity` is the Android-11 pattern; `topResumedActivity` kept as newer-firmware fallback. |
| B. Intent tracking (Android usage/intent logs) | Low–medium | Higher parse cost | Requires AccessibilityService/app instrumentation on modern Android — not viable via ADB shell. Reject. |
| C. Last-known-state cache (retain last session per package with timestamp) | Medium (supplementary) | Memory only | No extra polling; answers "what was this app doing". |
| D. logcat foreground listener | High for events | Part of the listener work | Covered by the hybrid loop above; R8 verified `ActivityTaskManager: START` lines. |
| **E. "Audio playback" section of `dumpsys media_session`** | High as a *hint* | Zero (same command) | NEW from R2: lists recently-playing audio packages even with no session (`ar.tvplayer.tv` shows there). Confidence check that a foreground app is actually playing media. |
**Recommendation: build A + C — with the Option-1 decision (§9), the absorbed
protocol client's `current_app` push is the *primary* foreground signal.** A
(`mResumedActivity` dumpsys) is the fallback when the protocol client is down
or silent; C (last-known-state cache) turns "foreground but no session" from
UNKNOWN into "in <app> (idle / last known paused)"; E enriches the fallback tier
with a "media recently active" hint at zero round-trip cost. D lands as the
ADB-side trigger in Phase 1.
Resulting state ladder for MVP: protocol is_on → intent → Jellyfin → media
session → foreground fallback (`current_app` push, then ADB `mResumedActivity`;
state = "on, in app X", with last-known metadata if any) → ON. Audio-playback
identity (E) enriches the fallback tier with a "media recently active" hint.
---
## 4. Phased MVP plan
### Phase 0 — Stabilize & trim (foundation; ~1–2 days)
Goal: make the codebase safe to rewrite. No behavior change.
Files:
- `custom_components/nvidia_shield/__init__.py` — introduce `entry.runtime_data`
placeholder (shared per-entry context object, initially just config mirror).
- `custom_components/nvidia_shield/const.py` — add TTL/staleness constants,
backoff constants; wire the currently-unused `JELLYFIN_APP_IDS` and add
`com.github.damontecres.wholphin` + `ar.tvplayer.tv` package constants.
- `custom_components/nvidia_shield/adb_session.py` + `ha_adb_common/adb_client.py`
— de-duplicate host resolution into one helper; make `shell()` propagate errors
(B3) while keeping current callers working; stop swallowing poll errors silently.
- `custom_components/nvidia_shield/media_player.py` — remove dead `jf_active`;
fix intent retry to use the path that originally succeeded; track+cancel poll
tasks on unload (B2); write state whenever any ADB-derived value changes.
- `tests/` (new) — parser fixtures (real `dumpsys media_session`, `activity
recents` outputs), intent-logic unit tests, config-flow smoke tests. Real
firmware fixtures are already captured — see the fixtures table in
`docs/RESEARCH_SHIELD.md`.
- `pyproject.toml`/CI — run ruff + pytest on PR.
Risks: parser fixture drift across Shield firmware (mitigate: fixtures from 2+
firmware versions, tolerant regexes). Fixing `shell()` error propagation can expose
previously hidden reconnect bugs — expected, ship together with the task-tracking
fix.
Done when: all tests green; no in-flight task survives entity unload (test
proves it); intent retry honors command path; ruff clean.
### Phase 1 — Core playback state + connection reliability (~3–5 days)
Goal: reliable, fast, persistent connection; media session is the source of truth.
Files:
- `custom_components/nvidia_shield/connection.py` (new) — `ShieldConnectionManager`:
persistent socket, watchdog + backoff reconnect, on-demand fetch API, StateStore
with timestamps, listener registration. API designed to host a second transport
(the remote protocol joins in Phase 2).
- `custom_components/nvidia_shield/adb_session.py` — becomes thin adapter over the
manager (keep `ensure_adb_key` re-export for config flow/button).
- `ha_adb_common/adb_client.py` — extract protocol-level commands (no loop
scheduling); add explicit `health_check()`.
- `ha_adb_common/media_session.py` — parse `PlaybackState` position (ms, with
uptime-anchored math — `updated` is uptime-ms per R9); add session-expiry at
store level.
- `media_player.py`, `sensor.py` — entities become views over StateStore;
state tiering unchanged; availability reflects manager connection health
(`_attr_available`).
- `manifest.json` — `iot_class: local_polling` stays until the protocol client
(push) lands in Phase 2, then becomes `local_push`.
- `config_flow.py` — pairing step reuses manager's test-connect (no behavior
change to flow).
Risks: `adb_shell` async stability over long-lived connections (mitigate:
watchdog pings + teardown/rebuild on protocol errors); reconnect storms during
device reboots (backoff + jitter); intent logic regression (covered by Phase-0
tests).
Done when: device reboot recovers automatically within backoff window; play/pause
reflected in ≤1 heartbeat cycle (≤30 s) worst case, foreground-triggered changes
faster; unavailable/available transitions correct; no state staleness beyond TTL;
tests for manager (connect/reconnect/expiry) pass.
### Phase 2 — All-in-one: absorb the remote protocol (androidtvremote2) (~4–6 days)
Goal: one integration, one device. We own the Google remote protocol for
commands, power and availability; ADB remains the playback-state source. The
external androidtv / androidtv_remote integrations are no longer required.
Files:
- `manifest.json` — add `androidtvremote2` requirement (`adb-shell` stays).
- `config_flow.py` — protocol pairing ported from core androidtv_remote: generate
cert if missing, fetch name + MAC (unique_id), PIN pair step (code shown on the
Shield), reauth flow on InvalidAuth (device hard reset). The existing ADB
pairing step stays intact — both pairings coexist in one flow.
- `connection.py` — manager now hosts both transports: protocol client
(`keep_reconnecting`, callbacks: is_on, is_available, current_app, volume_info)
+ ADB client; one StateStore fed by both.
- `remote.py` (new platform) — protocol-backed remote entity (port of core:
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
current app).
- `media_player.py` — command routing switches to protocol keys; volume gains a
push-based level readout + real mute state (NOTE: androidtvremote2 has NO
absolute volume set — HA core closed volume_set for androidtv_remote as
not_planned; only VOLUME_STEP | VOLUME_MUTE are claimed, level comes from
volume_info push); availability and is_on come
from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
plug off.
plug off.
- `media_player.py` `async_play_media` — §7 channel port via protocol
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
fallback when the protocol is down.
- `__init__.py` — PLATFORMS gains REMOTE; entry migration (VERSION 2); close
both clients on HA stop / unload.
Risks: two pairing experiences (ADB + PIN) need clear flow copy;
androidtvremote2's own reconnect task and our ADB watchdog must not fight each
other — one owner per transport; existing installs that wrap the external
androidtv_remote entity need a migration path (transitional fallback while it's
still configured); IME option defaults off.
Done when: entry sets up standalone (no androidtv/androidtv_remote entries
needed); remote entity sends keys and launches apps; real volume level/mute
shown; channel selection works via protocol with ADB fallback; device hard reset
triggers reauth; ADB pairing unchanged; migration covered by tests.
### Phase 3 — Fallback app state (~2–3 days, smaller now)
Goal: every app gets a sensible state even without a media session. The protocol
client's `current_app` push is the primary foreground signal; ADB fills gaps.
Files:
- `ha_adb_common/adb_client.py` (or new `foreground.py`) — `mResumedActivity`
parser (Android 11 primary pattern; `topResumedActivity` kept as a
newer-firmware fallback) — used only when protocol current_app is
None/unknown/stale. Do not parse `dumpsys window` focus first (multiple
`mCurrentFocus` lines incl. literal `null` — R4).
- `connection.py` — foreground priority in StateStore: protocol current_app push
> ADB `mResumedActivity`; last-known-state cache per package (option C);
audio-playback identity parsed from the same media_session dump (E).
- `ha_adb_common/media_session.py` — parse the "Audio playback (lastly played
comes first)" section into `{package: last_played_hint}`; ignore-list
`com.google.android.apps.mediashell` (Chromecast receiver process).
- `media_player.py` — state ladder gains the foreground tier; running_apps
enrichment with foreground/last-known info.
- `sensor.py` — Current App sensor reads StateStore (protocol push makes it
accurate without androidtv attribute reads).
- README — document the state ladder and fallback semantics; document the
migration away from mediasession2mqtt sensors + the universal-player YAML
glue (appendix in `docs/RESEARCH_SHIELD.md`).
Risks: dumpsys format differences across Shield Experience releases (fixtures
from real devices; R4); apps that keep background sessions while another is in
the foreground (session-vs-foreground preference already handles); last-known
cache must never resurrect stale "playing".
Done when: launching an app with no media session shows correct app + "on/idle"
within one update cycle; returning to an app with a session shows its state;
state tracks `current_app` push with no attribute polling; stale cache never
reports playing after >TTL; **`play_media` (`media_content_type: "channel"`)
selects TiviMate channels by number (protocol path, ADB fallback) per §7**;
tests cover all ladder branches.
### Phase 4 — QoL (~ongoing)
Files:
- Deep links — now cheap: `media_type: app/url` → protocol
`send_launch_app_command` (port of core pattern; the client auto-prefixes
`market://launch?id=` for bare package ids — C-LAUNCH, verified in the client
source, so no extra branch for app-id vs URI) + BrowseMedia app directory;
per-app `am start` extras via ADB only for apps the protocol can't launch
(data-driven map in const; discovery recipe via
`adb shell cmd package dump <pkg>` in RESEARCH_IDEAS.md).
- Channel keys: add `channel_up`/`channel_down` (proto keycodes 166/167 —
trivial, no UI needed) alongside the §7 digit path.
- Options: apps name/icon map editor (core androidtv_remote pattern), digit
delay, IME toggle, privacy scope; finish config entry migration if needed.
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
IP change; `manifest.json` gains the `zeroconf` integration dependency.
- `adb_command` service + diagnostics (port androidtv patterns).
- Optional: streaming_shell logcat stream as the trigger source (library
support verified on adb-shell 0.4.4, spike at goal round 9; pending a
multi-hour device stability spike) behind an option flag; default stays
delta snapshots.
- Trim: fold Current App sensor if StateStore covers it; drop
`running_apps_from_sessions` legacy; remove transitional external-entity code.
Risks: deep-link semantics differ per app (intent extras) — keep the app map
data-driven; options migration bugs (test entry migration).
Done when: deep links work for 2–3 reference apps; apps editor works; discovery
updates host on IP change; docs updated.
---
## 5. Dependency & compatibility notes
- Runtime deps: `adb-shell>=0.4.0` + `androidtvremote2` (adopted in Phase 2 —
small, HA-core-blessed); `zeroconf` integration dependency joins in Phase 4
for discovery. Logcat *streaming*
is the only thing that might eventually
need internals or a new dep — explicitly deferred and flagged.
- Vendored `ha_adb_common` stays for HACS; keep the hisense_tv consumer in mind
when changing its API (add, don't break; the fork can diverge later but Phase 0–2
changes should remain additive where possible).
- Pairing flow: ADB pairing (`async_step_adb_pair`, Pair ADB button,
`ensure_adb_key`, `.storage/nvidia_shield/adbkey` layout) is untouched in all
phases. The remote-protocol cert/PIN pairing (`.storage` certs) is *added*
alongside it in Phase 2.
## 6. Sequencing summary
| Phase | Theme | Primary outcome |
|---|---|---|
| 0 | Stabilize/trim, tests | Safe rewrite base; no behavior change |
| 1 | Persistent connection + session truth | Reliable fast playback state, auto-recovery |
| 2 | Absorb remote protocol (androidtvremote2) | All-in-one device; channel select; focused-app push |
| 3 | Foreground fallback + last-known state | Full-app coverage without sessions |
| 4 | Deep links, apps editor, discovery, diagnostics | Polish |
---
## 7. TiviMate channel selection (`play_media` port)
**Requirement:** retain `media_player.play_media` with `media_content_type: "channel"`
so TiviMate channels can be selected by number (e.g. `media_id: "1"`). This logic
lives today in the Android TV Remote integration; with Option 1 adopted we port
it onto our own protocol client (primary path) with ADB keyevents as fallback.
### Port design
- Add `MediaPlayerEntityFeature.PLAY_MEDIA` and implement `async_play_media(media_type, media_id)`.
- When `media_type == "channel"`: extract digits from `media_id`, then send each
digit as an ADB keyevent — `KEYCODE_0` = 7 … `KEYCODE_9` = 16 (digit n → keycode 7+n) —
with a configurable delay between digits (default ~0.5 s), then a confirm
keycode. Reference (verified from HA core `androidtv_remote/media_player.py`):
core sends digits ONLY — `media_id.isnumeric()`, cancels any prior in-flight
channel task (concurrency guard), `delay_secs=0.1`, and never sends a confirm
key: the device's channel overlay auto-selects. Port: digit n → keycode 7+n;
make ENTER an optional boolean (default off — replicates the androidtv_remote
behavior the user confirms works today).
- Other `media_type` values (`app`, `url`): launch via the protocol client's
`send_launch_app_command` once Phase 2 lands; until then reject unknown types
with a log warning.
- **Fallback chain:** protocol client down → ADB `input keyevent` digits (keycode
7+n, slower but socket-independent) → transitional only: delegate to the
still-configured external androidtv_remote entity (migration path, removed in
Phase 4). Order: protocol first, ADB second, legacy third.
- Command path: protocol `send_key_command` (push-based, fast, no extra shell
round trip); ADB keyevents only as the fallback (works whenever the socket is
up, regardless of the remote protocol's state).
- Timing risk: too-fast digits can be dropped or the overlay can close early —
make the inter-digit delay a `keyvalue` option with a safe default (0.1 s,
core-verified); the live check (R7) remains optional.
- Service-call note: current HA syntax passes `data.media.media_content_id` /
`media_content_type`; `async_play_media` receives `"channel"` / `"<n>"` the
same as before.
### Metadata implication
Channel *metadata* (EPG name, title) for TiviMate is a separate question from
channel *selection*:
- TiviMate: **no media session** (R2 verdict) → now-playing = Phase-3 foreground
fallback: state = "on, in TiviMate", title = `last_channel` cache when
play_media fires (cheap, honest, no EPG claim). Full EPG/EPG-logo data is out
of scope for the ADB-only MVP.
- Jellyfin-family clients: **Wholphin (`com.github.damontecres.wholphin`)**
publishes a proper session with usable metadata (R9) — routes through
`JELLYFIN_APP_IDS` to the rich-metadata path.
- Netflix publishes a stale inactive session (`active=false`, `state=null`) —
the parser must keep ignoring it; a fixture pins this behavior.
### Where it lands
- Phase 2 (the port target is the protocol client absorbed in Phase 2; TiviMate
state *display* comes from Phase 3's fallback).
---
## 8. Local research on the Shield — STATUS
**STATUS: DONE.** R1–R5, R8, R9 executed; R6 optional-not-run; R7 skipped by
decision. Full results, raw captures, and the user's current YAML glue are in
`docs/RESEARCH_SHIELD.md`.
| Step | Status | Key result |
|---|---|---|
| Setup | ✅ | Shield `mdarcy`, Android 11, `RQ1A.210105.003`; custom launcher `com.spocky.projengmenu` |
| R1 | ✅ | TiviMate package = **`ar.tvplayer.tv`** (plan's `iv.android.tv` guess was wrong) |
| R2 | ✅ | **No media session** for TiviMate (branch 3). Netflix stale inactive session only. NEW: "Audio playback" section lists `ar.tvplayer.tv` |
| R3 | ⏳ partial | Wholphin + Netflix captured; YouTube/Spotify/Plex optional |
| R4 | ✅ | `mResumedActivity` is the Android-11 pattern; no `topResumedActivity`; `mCurrentFocus=null` trap |
| R5 | ✅ | `mWakefulness=Awake/Asleep` — switch to exact value match |
| R6 | ⏳ | Not run (optional; low risk) |
| R7 | ⏭ skipped | Replicate core behavior (digits, 0.1 s, no ENTER) — user's existing flow works |
| R8 | ✅ | `ActivityTaskManager: START` lines reliable incl. home button; user has `be.digitalia.mediasession2mqtt` installed |
| R9 | ✅ | `PlaybackState{state=3, position (ms), speed, updated (uptime ms)}` parseable; Wholphin session good (`description=Twirlywoos`) |
---
## 9. HA core research: androidtv + androidtv_remote (what's worth taking)
Sources reviewed on `dev`:
[androidtv](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv) ·
[androidtv_remote](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv_remote).
Key findings first:
1. **`androidtv` (ADB component) has NO `async_play_media`** — channel-by-number
logic is not there. It lives in **`androidtv_remote/media_player.py`**, so §7's
port source is confirmed to be androidtv_remote (not androidtv).
2. **Both components ship `PLATFORMS = [MEDIA_PLAYER, REMOTE]`** — HA core's own
precedent for one device exposing a media_player + remote entity pair. That is
exactly the all-in-one shape requested.
3. `androidtv`'s remote entity is pure ADB (`input keyevent`) — a basic remote
entity needs no remote-protocol dependency (superseded by the Option-1
decision; its logic survives as our command fallback path).
### Worth taking from `androidtv` (ADB component)
| Pattern | File | Fold into |
|---|---|---|
| `entry.runtime_data` dataclass: client + options snapshot | `__init__.py` | Phase 1 manager (B1) — canonical HA pattern to mirror |
| `ConfigEntryNotReady` on first connect failure → HA itself retries setup | `__init__.py` | Phase 1 — better than our in-entity 300 s retry |
| Close connection on `EVENT_HOMEASSISTANT_STOP` | `__init__.py` | Phase 1 manager lifecycle |
| Options change **without full reload**: only reload for `RELOAD_OPTIONS`; otherwise re-read options via `async_dispatcher_send` | `__init__.py` update_listener | Proper fix for B6 (reload churn) |
| `adb_decorator`: uniform exception handling → log once, `adb_close()`, mark entity unavailable | `entity.py` | Phase 1 — replaces swallow-at-DEBUG `shell()` (B3) |
| Reconnect-on-update + `_failed_connect_count` (log first failure only) | `media_player.py` `async_update` | Phase 1 watchdog detail |
| `select_source` = launch app; `!name` prefix = close app; `source_list` built from running apps | `media_player.py` | Phase 3/4 QoL — natural fit with our existing `running_apps` |
| `adb_command` service, diagnostics | `services.py`, `diagnostics.py` | Phase 4 QoL |
### Worth taking from `androidtv_remote` (remote protocol)
| Pattern | File | Fold into |
|---|---|---|
| **Channel logic (the §7 port source)**: `media_type == CHANNEL`, `media_id.isnumeric()`, cancel previous in-flight channel `asyncio.Task`, `_send_key_commands(list(media_id), delay_secs=0.1)` — digits only, **no ENTER** | `media_player.py` | §7 |
| `app`/`url` media types → launch app; BrowseMedia app directory | `media_player.py` | Phase 4 deep links + browse |
| Entities as thin callback-views over one runtime API object (available / is_on / current_app / volume callbacks) | `entity.py` | Phase 1 manager → entity design |
| `keep_reconnecting()` + trigger reauth when the device hard-resets (cert invalid) | `__init__.py` | Phase 1/2 watchdog: distinguish "unreachable" (backoff) from "unpaired" (reauth) |
| Zeroconf discovery (`_androidtvremote2._tcp`, MAC as unique_id, host auto-update on IP change) | `config_flow.py` | Phase 4 QoL |
| Cert+PIN pairing, certs stored in `.storage` | `config_flow.py`, `helpers.py` | Phase 2 |
### All-in-one: the remote as an entity alongside the media_player — DECIDED
Today we *wrap* an external `androidtv_remote` entity (resolving its host from
its config entry). **Decision: Option 1 adopted** — androidtvremote2 is excellent
for *commands* but weak on receiving playback state; our ADB session tracking
fills exactly that gap, making the two transports complementary. Phase 2 absorbs
the protocol: we own the connection, one device provides `media_player` +
`remote`, and the `current_app` push becomes the primary foreground signal —
fewer dumpsys round trips and a more reliable Phase-3 fallback. ADB remains the
state source; the ADB pairing flow stays intact. The ADB-only remote entity
(Option 2) is not built as a separate platform; its `input keyevent` logic
survives as the command fallback path inside `async_play_media`/command routing.
+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).
+423
View File
@@ -0,0 +1,423 @@
# Shield firmware research results (§8 of MVP_PLAN.md, R1–R9)
Captured from the user's Shield. These outputs are the ground truth for parser
fixtures and resolved the R2 verdict. Plan deltas applied to `MVP_PLAN.md` are
listed at the end.
## Environment
| Property | Value |
|---|---|
| Device | NVIDIA Shield (board `mdarcy`) |
| Android | 11 (`ro.build.version.release=11`) |
| Fingerprint | `NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4231.0265:user/release-keys` |
| SDK | 30 (Android 11; the `getprop ro.build.version.sdk` call failed on shell truncation — `etprop: inaccessible or not found` — but Android 11 ⇒ SDK 30) |
Note: the home launcher is **not** the stock Shield launcher — it is
`com.spocky.projengmenu` (Project Eternity menu). Foreground detection must not
assume `com.google.android.tv.launcher`.
---
## R1 — TiviMate package name: `ar.tvplayer.tv`
```
$ adb shell pm list packages | grep -iE 'tivi|^package:iv'
package:ar.tvplayer.tv
```
**Verdict:** TiviMate's package is `ar.tvplayer.tv` — **not** `iv.android.tv` as
the plan assumed. All match keys, fixtures, and constants use `ar.tvplayer.tv`.
---
## R2 — Does TiviMate publish a media session? **NO** (verdict: branch 3)
Full `dumpsys media_session` with TiviMate in the foreground playing a live
channel (captured twice, before/after zapping):
```
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
Global priority session is null
User Records:
Record for full_user=0
...
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
```
**Findings:**
1. **No TiviMate session block at all.** TiviMate registers no MediaSession —
it cannot be reached via the session stack. It is a pure Phase-3 fallback app.
2. The only session present is **Netflix's, stale/inactive**: `active=false`,
`state=null`, `metadata=null`. The current parser requires `active=true` +
metadata, so it correctly ignores it — but this exact dump must become a
fixture pinning that behavior ("inactive no-state sessions are ignored").
3. The before/after-zap captures are **byte-identical** — zapping channels
changes nothing in `dumpsys media_session`.
### NEW discovery — the "Audio playback" section
The bottom of `dumpsys media_session` lists **apps that recently played audio**,
even with no MediaSession:
```
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
```
- TiviMate shows up here while playing live TV. This is a **zero-cost
supplementary signal**: it comes from the *same* `dumpsys media_session` call
the integration already makes — no extra round trip.
- Use: confirm "this foreground app actually has/played media" (foreground =
TiviMate + recent audio playback ⇒ live TV likely playing). Distinguishes
"sitting in TiviMate's settings" from "watching a channel".
- Note `com.google.android.apps.mediashell` (Chromecast receiver process) also
appears — a parse-time ignore-list entry.
---
## R4 — Foreground detection format (Android 11): `mResumedActivity` is the pattern
```
$ adb shell dumpsys activity activities | grep -iE 'resumed'
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}
```
```
$ adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
mCurrentFocus=null
mFocusedApp=null
mCurrentFocus=Window{980f3f1 u0 ar.tvplayer.tv/ar.tvplayer.tv.ui.MainActivity}
mFocusedApp=ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
```
**Findings:**
1. **No `topResumedActivity` on this firmware** (that pattern is newer Android).
Primary pattern = `mResumedActivity: ActivityRecord{… u0 <pkg>/<activity> …}`.
Keep `topResumedActivity` parsing for future-proofing, checked *after*
`mResumedActivity`.
2. `dumpsys window` contains **multiple** `mCurrentFocus` entries and the first
can be literally `mCurrentFocus=null` — a naive first-match parser breaks.
`mResumedActivity` is the reliable single source; window/focus parsing is a
secondary cross-check at most.
3. Package extraction: from the `u0 <pkg>/<activity>` fragment of the
ActivityRecord line (handles multi-line wraps in the raw dump — match on the
joined block, not per-line).
4. Launcher foreground (for the "off/idle in launcher" distinction): with the
user's custom launcher focused, `mResumedActivity` shows
`com.spocky.projengmenu/.ui.home.MainActivity`.
---
## R5 — Wake state: exact value match confirmed
```
Awake: mWakefulness=Awake
mWakefulnessChanging=false
Asleep: mWakefulness=Asleep
mWakefulnessChanging=false
```
**Verdict:** the current `"Awake" in result` substring heuristic happens to
work on this firmware, but switch to an exact value parse of the
`mWakefulness=` assignment (robust against future values like
`mWakefulness=Dozing`).
---
## R6 — Recent-tasks: **not run** (optional; low risk)
Still outstanding if `media_dag_running_apps` needs verification on this
firmware.
---
## R7 — Channel digit keyevents: **skipped by decision**
The user already uses `media_player.play_media` with
`media_content_type: channel` through the androidtv_remote integration and
reports it "works perfectly fine" — so we **replicate core's exact behavior**
rather than live-testing keyevent variants:
- digits only (no confirm/ENTER key), inter-digit delay **0.1 s** (core default),
- cancel any prior in-flight channel task before sending,
- keep ENTER as an *optional* boolean (default off) in case a future app needs it.
R7 stays in the doc as an optional verification step if protocol-path digits
ever misbehave.
---
## R8 — Logcat event reliability: `ActivityTaskManager: START` lines confirmed
```
10-08 06:54:33.748 3693 5205 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
flg=0x10200000 cmp=ar.tvplayer.tv/.ui.MainActivity} from uid 2000
10-08 06:54:50.439 3693 5212 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN
cat=[android.intent.category.LEANBACK_LAUNCHER] flg=0x10220000
pkg=com.spocky.projengmenu cmp=com.spocky.projengmenu/.ui.home.MainActivity
(has extras)} from uid 10059
```
**Findings:**
1. `ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` lines are emitted on
every app open — including the **home button** (which sends a
`LEANBACK_LAUNCHER` START for the launcher). App-switch detection via logcat
delta snapshots is viable in both directions.
2. `WindowManager` lines were absent from the capture — rely on
`ActivityTaskManager` first.
3. The user has **`be.digitalia.mediasession2mqtt`** installed
([MediaSession2MQTT](https://github.com/cbeyls/MediaSession2MQTT)) — a
notification-listener service that publishes media sessions over MQTT. It is
part of their current glue (see appendix) and is a candidate to retire once
our session + fallback path covers its sensors.
---
## R9 — PlaybackState layout + a known-good Wholphin session
```
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
```
**Findings:**
1. `PlaybackState` carries everything we planned to parse: `state=3` (playing),
`position=17237` (ms), `speed=1.0`, `updated=1408392` (**uptime ms**, needs
conversion — position math must account for the uptime-vs-wallclock gap;
mirror HA's convention by computing `position + (now - updated_time)` with
uptime anchoring).
2. No duration in PlaybackState — duration lives in metadata keys
(`android.media.metadata.DURATION` inside the size=11 metadata dict, beyond
the `description=` field the parser currently reads). Parsing the full
metadata key list is a Phase-1 stretch goal; `description=` gives
title/artist/album positionally as before.
3. The session above is **Wholphin** (`com.github.damontecres.wholphin`) — a
Jellyfin client that publishes a proper session with usable metadata. Add it
to `JELLYFIN_APP_IDS` (that constant is currently defined but **unused** —
wire it up) so Jellyfin-family clients route to the rich-metadata path.
---
## play_media channel — current working service call (replicate exactly)
```yaml
action: media_player.play_media
data:
media:
media_content_id: '{{ channel_number|int }}'
media_content_type: channel
metadata: {}
target:
entity_id:
- media_player.shield_android_tv
```
Note the current HA service syntax uses the `media:` block; the entity's
`async_play_media(media_type, media_id)` receives `"channel"` / `"<n>"` the same
as before.
---
## Appendix — the user's current glue this integration replaces
Recorded as requirements context. This is what works today and what MVP should
make unnecessary.
### Template sensor (app name from the androidtv media_player)
```yaml
template:
- triggers:
- trigger: homeassistant
event: start
- trigger: state
entity_id:
- media_player.android_tv_10_50_204_4
attribute: app_name
sensor:
- name: Nvidia Shield App Name
unique_id: media_player_nvidia_shield_app_name
icon: "mdi:application"
state: >
{% set adb = "media_player.android_tv_10_50_204_4" %}
{% set adb_state = states(adb) %}
{% if adb_state not in ['unknown', 'unavailable', 'off'] %}
{{ state_attr(adb,'app_name') }}
{% elif adb_state in ['off'] %}
{{ "off" }}
{% else %}
{{ "unknown" }}
{% endif %}
```
### Universal media player (state merging + command routing)
```yaml
media_player:
platform: universal
name: Nvidia Shield
device_class: tv
unique_id: media_player_nvidia_shield
children:
- media_player.android_tv_10_50_204_4
browse_media_entity: media_player.android_tv_10_50_204_4
state_template: >-
{% set shield_state = states('media_player.android_tv_10_50_204_4') %}
{% set known_app = states('sensor.mediasession2mqtt_2044_application_id') in ["com.google.android.youtube.tv", "com.github.damontecres.wholphin"] %}
{% set wholphin = is_state('sensor.nvidia_shield_app_name', 'Wholphin') %}
{% if(shield_state == "unknown" or shield_state == "unavailable" or shield_state == "off") %}
off
{% elif wholphin %}
{% set playing_state = is_state('binary_sensor.jellyfin_nvidia_shield_playing', 'on') %}
{% set paused_state = is_state('binary_sensor.jellyfin_nvidia_shield_paused', 'on') %}
{% if paused_state %}
paused
{% elif playing_state %}
playing
{% else %}
idle
{% endif %}
{% elif known_app %}
{{ states("sensor.mediasession2mqtt_2044_playback_state") }}
{% else %}
{{ shield_state }}
{% endif %}
commands:
turn_on:
action: script.turn_on_nvidia_shield
turn_off:
action: script.turn_off_nvidia_shield
volume_mute:
action: switch.toggle
target: {entity_id: switch.tv_cabinet_livingroom_speaker_system}
volume_up:
action: script.volume_up_nvidia_shield
volume_down:
action: script.volume_down_nvidia_shield
volume_set:
action: script.volume_set_nvidia_shield
data: {level: "{{ volume_level }}"}
select_source:
action: script.set_specific_app_nvidia_shield
data: {app_name: "{{ source }}"}
attributes:
is_volume_muted: switch.inverse_tv_cabinet_speakers
volume_level: media_player.android_tv_10_50_204_4|volume_level
source: remote.shield_android_tv|current_activity
source_list: remote.shield_android_tv|activity_list
```
### What the MVP replaces vs. keeps
| Current glue | MVP after Phase 2/3 |
|---|---|
| `sensor.nvidia_shield_app_name` template sensor | Current App sensor reads StateStore (protocol `current_app` push) |
| `mediasession2mqtt` sensors (`*_application_id`, `*_playback_state`) | ADB media sessions + fallback state (retire the MQTT bridge) |
| Universal player `state_template` app special-casing | Native state ladder |
| `browse_media_entity` from androidtv_remote | Phase 4 BrowseMedia app directory |
| `volume_*` scripts (speaker switch + receiver interplay) | **Stays as user automations** — but `volume_set` gains a real protocol path (Phase 2 VOLUME_SET); the receiver/speaker switch logic remains the user's or moves to media zone |
| `remote.shield_android_tv` for source/activity | Our own remote entity (Phase 2) |
---
## Fixtures to save into `tests/fixtures/`
| File | Content |
|---|---|
| `dumpsys_media_session_tivimate_playing.txt` | R2 capture 1 (Netflix stale session + audio playback listing) |
| `dumpsys_media_session_tivimate_zapped.txt` | R2 capture 2 (identical — pins zap-invariance) |
| `dumpsys_media_session_wholphin_playing.txt` | R9 capture (playing session with position/metadata) |
| `dumpsys_activity_activities_tivimate.txt` | R4 capture 1 |
| `dumpsys_activity_activities_launcher.txt` | R4 capture 2 (custom launcher) |
| `dumpsys_window_focused.txt` | R4 window capture |
| `dumpsys_power_wakefulness_{awake,asleep}.txt` | R5 captures |
| `logcat_activitytaskman_start.txt` | R8 capture |
---
## Plan deltas applied to `MVP_PLAN.md`
1. TiviMate package corrected everywhere: `ar.tvplayer.tv`.
2. R2 verdict resolved: no session → TiviMate is the headline Phase-3 fallback
app; README gets it as a known case.
3. **New fallback signal adopted:** "Audio playback (lastly played comes first)"
section of `dumpsys media_session` — zero extra round trip; parsed alongside
sessions; `com.google.android.apps.mediashell` in the ignore list.
4. Foreground parser: `mResumedActivity` primary (Android 11),
`topResumedActivity` newer-fallback; `mCurrentFocus=null` trap documented;
launcher special-casing not stock.
5. Wake parse: exact `mWakefulness=` value match.
6. `play_media(channel)`: replicate core behavior exactly (digits, 0.1 s, no
ENTER); R7 marked skipped-by-decision.
7. Wholphin (`com.github.damontecres.wholphin`) added to `JELLYFIN_APP_IDS`;
wiring that constant becomes a Phase-0/1 item.
8. Position math: `updated` is uptime-ms — needs uptime anchoring in Phase 1.
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
Phase 3 lands (documented in README).
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.
+5 -8
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ha-nvidia-shield" name = "ha-nvidia-shield"
version = "0.1.1" version = "2026.10.08"
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote" description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -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")
+385
View File
@@ -0,0 +1,385 @@
"""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.exceptions",
"homeassistant.core",
"homeassistant.const",
"homeassistant.components",
"homeassistant.components.media_player",
"homeassistant.components.remote",
"homeassistant.components.button",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_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,
domain=None, # t30 G18 (2026-05-20 blog contract)
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.domain = domain
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", (), {})
async def _stub_set_unique_id(self, unique_id):
# hub shape: record; a full flow test file scopes its own version
self._unique_id = unique_id
def _noop_abort(self, *, error=None, **kwargs):
return None
class _ConfigFlowStub:
"""ConfigFlow shape used by entity tests (t29 G3a API included).
Late loads under the hub stubs (e.g. test_init designs run AFTER the
other flow files) still get the base plumbing the flow modules rely
on — show/create/abort/unique-id — so the flow classes never import
against a bare base.
"""
async_set_unique_id = _stub_set_unique_id
_abort_if_unique_id_configured = _noop_abort
def _async_handle_discovery_without_unique_id(self):
# t32 F2 (audited helper): recording no-op at hub scope
pass
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}
def async_abort(self, *, reason):
return {"type": "abort", "reason": reason}
def _get_reauth_entry(self, entry=None):
if entry is not None:
return entry
return None # the caller's cached entry is used
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
# which updates, schedules the reload and aborts; the stub records both.
def async_update_reload_and_abort(self, entry, **kwargs):
"""Real-HA semantics: update, schedule the reload, abort."""
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
for key, value in updates.items():
if key in ("options", "data", "unique_id"):
setattr(entry, key, value)
else:
getattr(entry, "options", {}).setdefault(key, value) # best effort
scheduler = getattr(self.hass, "config_entries", None)
if scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
def _config_flow_init(self, *args, **kwargs):
self.context = getattr(self, "context", {}) or {}
self.hass = getattr(self, "hass", None)
_config_flow_base = type(
"ConfigFlow",
(_ConfigFlowStub,),
{
"__init_subclass__": classmethod(
lambda cls, domain=None, **kwargs: None
),
"__init__": _config_flow_init,
},
)
config_entries.ConfigFlow = _config_flow_base
class _OptionsFlowStub:
def __init__(self):
self.config_entry = None
def add_suggested_values_to_schema(self, schema, suggested):
# real HA merges hints; the stub records them on the schema dict
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class _OptionsFlowWithReload(_OptionsFlowStub):
"""Options flow that reloads after a successful save (real HA)."""
def async_create_entry(self, *, title, data, options=None):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _OptionsFlowStub
config_entries.SOURCE_IMPORT = "import"
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
sys.modules["homeassistant.config_entries"] = config_entries
exceptions = types.ModuleType("homeassistant.exceptions")
class ServiceValidationErrorStubError(Exception):
def __init__(self, *args, **kwargs):
super().__init__(*args or (kwargs.get("translation_key"),))
self.translation_domain = kwargs.get("translation_domain")
self.translation_key = kwargs.get("translation_key")
exceptions.ServiceValidationError = ServiceValidationErrorStubError
sys.modules["homeassistant.exceptions"] = exceptions
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
sensor_platform = types.ModuleType("homeassistant.components.sensor")
sensor_platform.SensorEntity = type("SensorEntity", (), {})
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
sys.modules["homeassistant.components.sensor"] = sensor_platform
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()
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
class _DeviceRegistry:
def __init__(self):
self.devices: dict[str, object] = {}
def async_get(self, device_id):
return self.devices.get(device_id)
_device_registry_shared = _DeviceRegistry()
device_registry.async_get = lambda hass: _device_registry_shared
sys.modules["homeassistant.helpers.device_registry"] = device_registry
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_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
entity.EntityCategory = types.SimpleNamespace(
CONFIG="config", DIAGNOSTIC="diagnostic"
)
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",
"sensor",
"media_player",
"remote",
"diagnostics",
"init",
): # "init" maps the integration __init__.py (t29 G8 async_setup)
if name == "init" and "nsh.init" in sys.modules:
continue
if f"nsh.{name}" not in sys.modules:
file_name = "__init__.py" if name == "init" else f"{name}.py"
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
return {
name: sys.modules[f"nsh.{name}"]
for name in (
"power",
"jellyfin",
"sensor",
"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() == []
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
"""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.device_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
class _BaseOptionsFlow:
pass
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _BaseOptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
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
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
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"}
+424
View File
@@ -0,0 +1,424 @@
"""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.device_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
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
# t29 G3a: the current config-entries API surface on the local stub
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
def _update_reload_and_abort(self, entry, **kwargs):
updates = kwargs.get("options_updates") or {}
options = entry.options
for key, value in updates.items():
options[key] = value
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
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
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
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
+271
View File
@@ -0,0 +1,271 @@
"""tests/test_init.py — the t30 G6 structure test file.
Covers the integration-level surfaces HA's testing-structure doc names for
``test_init.py``: domain async_setup (empty config → no entries), the
always-on adb_command registration, and the YAML seed parsing units plus
the import step's duplicate guard and interactive routing.
"""
import asyncio
import sys
from types import SimpleNamespace
import entity_stublib
import pytest
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
# scoped_stubs pattern): the config-flow files install their own stub faces
# and pop them per-test; test_init's runs must never leave a hub-stub set
# behind that a later file's loader would bind against.
_INIT_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
# the G8 lazy import in async_setup registers this platform module —
# without popping it the install() guard would skip the rebuild below
"homeassistant.components.media_player",
"nsh.helpers",
"nsh.config_flow",
"nsh.init",
"nsh.media_player",
)
@pytest.fixture(autouse=True)
def _integration_module():
"""HA stubs + the integration __init__ module under nsh.init."""
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES}
entity_stublib.install()
if "nsh.init" not in sys.modules:
import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
yield sys.modules["nsh.init"]
for name in _INIT_STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
class _SeedEntriesStub:
"""config-entries shape for flow seeding tests: entries + flow records."""
def __init__(self, entries=()):
self._entries = list(entries)
self.flows = [] # (domain, context, data) records of flow.async_init
def async_entries(self):
return list(self._entries)
class _Flows:
def __init__(self, holder):
self._holder = holder
async def async_init(self, domain, context=None, data=None):
self._holder.flows.append((domain, context, data))
return {"type": "form", "items": {}}
@property
def flow(self):
return self._Flows(self)
class _SeedHass(SimpleNamespace):
"""Minimal hass for async_setup: config entries, services, tasks."""
def __init__(self, entries=()):
config_entries = _SeedEntriesStub()
config_entries.updating_entry_calls = []
self._services = {("nvidia_shield", "adb_command"): None}
super().__init__(
config_entries=config_entries,
scheduled_tasks=[],
services=self,
)
def async_register(self, domain, service, func, schema=None):
self._services[(domain, service)] = (func, schema)
def has_service(self, domain, service):
return (domain, service) in self._services
def async_create_task(self, coro):
task = asyncio.ensure_future(coro)
self.scheduled_tasks.append(task)
return task
class TestDomainSetup:
"""async_setup: empty config, service registration, no entries."""
async def test_empty_config_creates_nothing(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(hass, {})
assert result is True
assert hass.config_entries.flows == [] # nothing seeded
assert hass.services.has_service("nvidia_shield", "adb_command")
class TestYamlSeedBlocks:
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
def test_block_normalized(self, _integration_module):
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT
blocks = _integration_module._yaml_seed_blocks(
[{"name": "Living", "adb_host": "10.0.0.31"}]
)
assert len(blocks) == 1
seed = blocks[0]
assert seed["name"] == "Living"
assert seed[CONF_ADB_HOST] == "10.0.0.31"
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
assert seed["adb_enabled"] is True
assert "unique_id" not in seed # optional key only
def test_single_block_and_protocol_hint(self, _integration_module):
blocks = _integration_module._yaml_seed_blocks(
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
)
assert len(blocks) == 1
# protocol fields are prefill hints ONLY (pairing stays interactive)
assert blocks[0]["protocol_host"] == "shield.local"
assert blocks[0]["adb_port"] == 5000
def test_list_and_single_block_shapes(self, _integration_module):
single = _integration_module._yaml_seed_blocks({"name": "A"})
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
assert len(single) == 1 and len(listed) == 2
class TestSeeding:
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
async def test_shaped_block_seeds_a_flow(self, _integration_module):
hass = _SeedHass(entries=[])
await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
)
if hass.scheduled_tasks:
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
assert len(hass.config_entries.flows) == 1
domain, context, data = hass.config_entries.flows[0]
assert domain == "nvidia_shield"
assert context["source"] == "import"
assert data["adb_host"] == "10.0.0.5"
async def test_hostless_block_never_seeds(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "X"}]}
)
assert result is True
assert hass.config_entries.flows == [] # hostless → warning only
class TestImportStep:
"""async_step_import: duplicate guards + interactive routing."""
def _flow(self, entries):
# the flow module loads under these stubs and the base plumbing is
# injected locally — importing test_standalone_setup here would
# leak its local stub set into later test files.
from nsh.config_flow import NvidiaShieldConfigFlow
hass = SimpleNamespace(
config_entries=_SeedEntriesStub(entries),
)
hass.config_entries.flows = []
flow = NvidiaShieldConfigFlow()
flow.hass = hass
flow.context = {}
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
flow.async_set_unique_id = None # set below
flow._collected_unique_id = None
async def set_unique_id(uid):
flow._collected_unique_id = uid
flow.async_set_unique_id = set_unique_id
def abort_if_unique_id_configured(*args, **kwargs):
for entry in hass.config_entries.async_entries():
if entry.unique_id == flow._collected_unique_id:
from nsh.config_flow import AbortIfConfiguredError
raise AbortIfConfiguredError(f"{entry.unique_id}")
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
# the base ConfigFlow shows forms through the stub plumbing (matching
# how the standalone flow tests install it):
flow.async_show_form = lambda **kwargs: {
"type": "form",
**kwargs,
}
flow.async_create_entry = lambda **kwargs: {
"type": "create_entry",
**kwargs,
}
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
flow._async_current_entries = lambda: list(
hass.config_entries.async_entries()
)
return flow
async def test_ui_configured_host_never_re_seeded(self):
from nsh.const import CONF_ADB_HOST
existing = SimpleNamespace(
entry_id="ui1",
unique_id=None,
options={CONF_ADB_HOST: "10.0.0.31"},
)
# options live on the entry (conf zeroconf guard reads them)
flow = self._flow([existing])
result = await flow.async_step_import(
{"name": "A", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_virtual_unique_id_blocks_reseed(self):
flow_holder = SimpleNamespace(
entry_id="seed1",
unique_id="yaml:Living:10.0.0.31",
options={},
)
flow = self._flow([flow_holder])
result = await flow.async_step_import(
{"name": "Living", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_fresh_block_routes_into_the_user_flow(self):
flow = self._flow([])
result = await flow.async_step_import(
{"name": "Fresh", "adb_host": "10.0.0.99"}
)
# interactive pairing — the pre-filled user step, one visit
assert result["type"] == "form"
assert result["step_id"] == "user"
+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"
File diff suppressed because it is too large. Load diff