8.6 KiB
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_intentaction. 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:, thenactivity,meminfo,procstats,usagestats,batterystats; - the package section begins with an
Activity Resolver Table:listing, per component/filter, theAction:,Category:,Scheme:,Authority:andPath:lines, followed byReceiver Resolver Table:andService Resolver Table:; - Netflix's table contains real, usable entries (
cast:,http/httpswithwww.netflix.compath 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 isshlex.quoted before it reaches the device. - A missing required field is a
ServiceValidationErrornaming the field. - An optional
rawfield type passes extras verbatim for advanced cases, documented as unvalidated — the escape hatch, not the default.
4. Surfaces
- 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. nvidia_shield.send_intent—intent: <name>(a dropdown of registered intents) plusvariables: {…}supplying that intent's declared fields. This is how per-intent inputs arrive from automations, scripts and dashboards; it mirrorsscript.turn_onwith variables because HA offers no clean way to register a service per config entry. The action returns the device'sam startoutput, so a failure is visible.- 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.
- YAML seeding — a
registered_intents:block, honoured only when the UI holds nothing (seed-only ruling).
5. Failure modes and honesty
am startfailures (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
- Does
send_intentreturning a response needSupportsResponse.OPTIONALon our HA floor? Verify against the real installed HA before building on it. - 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.
- Broadcasts/receivers (
am broadcast) — the tables are right there — are out of scope for v1; activities and actions only. - Rename
ha_adb_common/intent.py→playback_intent.pyso the newintents.pyis not confused with it? Small churn, real clarity gain. - Should discovery be exposed as a service as well as a UI screen? Recommendation: UI only in v1.
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_commandremains 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.