Files
ha-nvidia-shield/docs/INTENTS_PLAN.md
T

8.6 KiB
Raw Permalink Blame History

Intent catalogue and registered actions (plan)

Scope: let the user discover the Android intents an installed app advertises, register the ones worth having, and fire them from Home Assistant — one button per registered intent, plus a send_intent action for the ones that need inputs.

Status: design agreed in principle by the user (2026-10-12). Not built. Companion docs: docs/MVP_PLAN.md (architecture), docs/RESEARCH_SHIELD.md (device captures), docs/RESEARCH_IDEAS.md (adjacent ideas).

Decisions locked (user):

  • Registered buttons, set up inside our options menu, plus a send_intent action. Verbatim: "The registered buttons, pre-setup within our menu options and a send_intent command seem the best method here. Less messy than the current adb command method of doing that."
  • Discovery is explicit (the user scans an app), never an automatic sweep.
  • YAML stays seed-only (the standing ruling: we load it only when no UI config exists).

1. Where we are today

Surface State
media_player.play_media with a URL Works — sends a VIEW intent through the ADB path
nvidia_shield.adb_command Works but is a raw escape hatch, and until t14 cannot even be aimed at the Shield from the UI
Intent discovery Does not exist
Intent registration Does not exist
ha_adb_common/intent.py Not Android intents — it is PlaybackIntent, the optimistic-UI dataclass. The name collision is a readability trap worth fixing (§7.4)

So the user's memory of "scanning the available commands for ADB between applications" is a real gap, not something built and forgotten.

What the device actually exposes (observed, one Shield, Android 11)

From the user's capture of pm dump com.netflix.ninja on 2026-10-12:

  • the output is ~2241 lines and is a whole dumpsys — DUMP OF SERVICE package:, then activity, meminfo, procstats, usagestats, batterystats;
  • the package section begins with an Activity Resolver Table: listing, per component/filter, the Action:, Category:, Scheme:, Authority: and Path: lines, followed by Receiver Resolver Table: and Service Resolver Table:;
  • Netflix's table contains real, usable entries (cast:, http/https with www.netflix.com path globs, nflx:, netflix:, MAIN + LEANBACK_LAUNCHER) and a large amount of internal noise (com.netflix.action.DEBUG_*, androidx.*, com.google.firebase.*, ConstraintProxy*).

Everything in §2 depends on that shape. It is observed on one firmware, so the parser must degrade honestly rather than assume.


2. Discovery

Source of truth: the app's activity resolver table.

Filter on the device — on this Shield an unfiltered pm dump ships 2241 lines over ADB for one app:

pm dump <pkg> | sed -n '/Activity Resolver Table/,/Service Resolver Table/p'

with a bounded grep -A fallback when the device's sed/toolbox cannot do ranges. Keep the transfer to the resolver section; the rest of the dump is never needed.

Rules:

  • One package at a time, on demand, triggered by the user from the Intents sub-screen. Never a sweep of every installed app (that is 20 × ~2241 lines on this device).
  • Cached per package with a TTL, in the same spirit as the app-label cache; a scan that ran and found nothing is a real answer, a scan whose transport failed is not (the lesson from the discovery-cache defect).
  • Rank, do not hide. Flag internal-looking filters (DEBUG_*, androidx.*, com.google.firebase.*) rather than deleting them — the user decides what is worth registering, and we do not get to guess.

A cheaper action-first query also exists (cmd package query-activities --brief -a …). Not verified on this device; treat as a possible optimisation, not the primary path.

Candidates are deduplicated on (component, action, categories, scheme/authority/path).


3. Data model — a registered intent

registered_intents:
  - id: netflix_title            # slug; also the button's key
    name: Netflix — open title   # what the user sees
    package: com.netflix.ninja
    activity: .MainActivity      # either a component…
    action: android.intent.action.VIEW   # …or an action (+ categories)
    categories: [android.intent.category.DEFAULT]
    data: "https://www.netflix.com/watch/{id}"   # URI template, optional
    fields:                      # declared inputs, with defaults
      - {key: id, type: string, required: true}

Mapping to am start (through the existing ADB path):

Declared am start
component -n com.netflix.ninja/.MainActivity
action -a android.intent.action.VIEW
categories -c … (repeated)
data template -d <resolved>
type: string --es key value
type: int --ei key value
type: bool --ez key value

Notes that matter:

  • No eval, no shell interpolation. {placeholder} substitution is plain string replacement, and every value is shlex.quoted before it reaches the device.
  • A missing required field is a ServiceValidationError naming the field.
  • An optional raw field type passes extras verbatim for advanced cases, documented as unvalidated — the escape hatch, not the default.

4. Surfaces

  1. One button entity per registered intent — button.<name>, pressing it runs the intent with the declared defaults. This is the "register an action for each one" the user asked for, and it is the whole point for no-input intents.
  2. nvidia_shield.send_intent — intent: <name> (a dropdown of registered intents) plus variables: {…} supplying that intent's declared fields. This is how per-intent inputs arrive from automations, scripts and dashboards; it mirrors script.turn_on with variables because HA offers no clean way to register a service per config entry. The action returns the device's am start output, so a failure is visible.
  3. Intents sub-screen in the options flow, the same idiom as the Apps screen: Add an intent (pick package → pick a discovered candidate or enter one manually → name it → declare its fields), Edit, Remove, Scan an app's intents.
  4. YAML seeding — a registered_intents: block, honoured only when the UI holds nothing (seed-only ruling).

5. Failure modes and honesty

  • am start failures (unresolved intent, permission denied, activity gone after an app update) surface the device's own message and raise; never a silent success.
  • ADB is required. The protocol transport cannot send intents; when ADB is down the error says so instead of failing obscurely.
  • An unknown intent name raises, listing the known names.
  • An intent that the app rejects is a config problem, and the docs say so plainly.
  • Nothing registered ever runs by itself, and discovery never auto-registers anything.

6. Costs

  • Discovery: one filtered dump per scanned package, cached.
  • Invocation: one ADB shell per press.
  • Storage: registered intents are ordinary options data.

7. Open questions

  1. Does send_intent returning a response need SupportsResponse.OPTIONAL on our HA floor? Verify against the real installed HA before building on it.
  2. Typed fields (string/int/bool) for v1, or string-plus-raw only? Recommendation: typed, since that is what makes the dropdown-plus-variables model pleasant.
  3. Broadcasts/receivers (am broadcast) — the tables are right there — are out of scope for v1; activities and actions only.
  4. Rename ha_adb_common/intent.py → playback_intent.py so the new intents.py is not confused with it? Small churn, real clarity gain.
  5. Should discovery be exposed as a service as well as a UI screen? Recommendation: UI only in v1.
  6. play_media(media_content_type="intent")? Recommendation: no — the button and the action are clearer, and play_media already carries the URL/VIEW case.

8. Proposed tickets

Unit Contents
Discovery ha_adb_common/intents.py + the filtered on-device dump + parser + cache + tests, with a fixture cut from the user's real Netflix capture
Registration & invocation Intents sub-screen, per-intent fields, button entities, send_intent action, mapping, docs, strings, tests
Review Adversarial verification of both, including a rejected-intent and an ADB-down path

9. Non-goals

  • No automatic intent execution or auto-registration from discovery.
  • No arbitrary shell passthrough in send_intent (adb_command remains that door).
  • No broadcasts/receivers in v1.
  • No extras beyond string/int/bool/raw.
  • Not a replacement for the app source list or play_media.