feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2)

Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.

THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.

Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.

The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.

THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.

THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.

THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.

test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).

Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
This commit is contained in:
James Mackay committed 2026-10-12 12:54:41 +01:00
1 parent a8041e1428
commit 259a6c29bd
7 files changed
+2715 -27

No files matched your search

+109 -1
View File
@@ -151,7 +151,32 @@ address are ignored with a warning.
The seed carries the address, the ADB toggle and the entry's name; everything
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
`adb_command` service) is set on the entry's **Configure** page after setup, or
during the interactive flow.
during the interactive flow. `registered_intents:` is the one exception — it may
be seeded too, because registering several intents by hand is exactly the kind of
work a `configuration.yaml` should be able to do:
```yaml
nvidia_shield:
- name: Living Room
host: 10.0.0.31
registered_intents:
- id: netflix_title # the button's key (optional)
name: "Netflix: open a title" # what the button and the action call it
package: com.netflix.ninja
activity: com.netflix.ninja/.MainActivity # a component…
action: android.intent.action.VIEW # …or an action (+ categories)
categories: [android.intent.category.DEFAULT]
data: "https://www.netflix.com/watch/{id}" # {placeholders} from the fields
fields:
- {key: id, type: string, required: true}
```
A seeded intent is **identical to one you typed** — same validation, same button,
same `send_intent` behaviour. The seed is honoured **only while the UI holds
nothing**: it is applied when the entry is created (the Intents screen lives in
the options flow and never writes a seed back), and an existing entry is never
re-seeded. A seeded row that does not validate is **dropped with a warning naming
it**, because there is no form to show an error on in `configuration.yaml`.
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
@@ -215,6 +240,89 @@ This is the same security posture as the Home Assistant core `androidtv`
integration: a documented, opt-in escape hatch for debugging and automation,
registered at setup and unregistered when the last participating entity unloads.
### Intents (buttons and the `send_intent` action)
An **intent** is one thing an app can be asked to do: open a screen, follow a
deep link, handle an action. This integration carries them over ADB, in two
halves.
**Discovery** asks the Shield what ONE app advertises. The **Scan an app's
intents** row on the Intents screen runs it for the app you pick: the resolver
section of that app's `pm dump` is filtered *on the device* (the whole dump is
~2241 lines), only the section crosses the connection, and the answer is cached
per package for an hour. A scan never registers anything — it offers candidates,
and you pick one. A candidate whose path is a *pattern* (`/title.*`) prefills no
URL on purpose: a pattern is not a link you can open.
**Registration** turns a candidate (or something you type) into a stored intent.
Configure → **Intents** → *Add or edit an intent*:
| Field | What it is |
| --- | --- |
| Name | what the button and the action's dropdown call it; unique per entry |
| Package id | the app's Android package id, exactly as the Shield reports it |
| Activity (component) | the component to launch, `pkg/.Activity` — use this **or** the action |
| Action | an Android action such as `android.intent.action.VIEW` |
| Categories | optional, comma separated (`android.intent.category.DEFAULT`) |
| Data (URL template) | optional URL/URI; `{variable}` placeholders are filled from the declared variables |
| Variables (declared fields) | one per line: `key:type[:required][=value]`, where type is `string`, `int`, `bool` or `raw` |
**One button per registered intent.** Adding an intent adds `button.<name>`
pressing it sends the intent with the declared defaults. A declared field that is
`required` with no default makes the press an error naming the field — such an
intent is meant to be called through the action, which is where variables come
from. Removing the intent removes its button (an options change reloads the
entry). The button's attributes show exactly what it will send.
**The action** takes the intent's name and its variables:
```yaml
- action: nvidia_shield.send_intent
target:
entity_id: media_player.shield
data:
intent: "Netflix: open a title"
variables:
id: "80100172"
response_variable: fired # optional: keep the output in a variable
```
- `intent` (**required**) — the registered name, as the Intents screen shows it.
An unknown one raises `unknown_intent` and **lists the known names**.
- `variables` — one value per declared field. A declared field with a default may
be omitted; a `required` one raises `missing_intent_field`, **naming the field**.
A variable the intent never declared is **refused** (`unknown_intent_variable`):
nothing silently travels to the device, and nothing is silently dropped. A value
that cannot become its declared type names the field and the value
(`invalid_intent_field`).
- The **target is required** and resolves exactly like `adb_command`: one Shield
(`no_target` / `unsupported_target` / `ambiguous_target`), never a guess.
- **The device's own output comes back** in the response under `intent_response`
(`am start` prints `Starting: Intent {…}` when it accepted the intent), so a
caller that asks for a response can see what happened.
- **Failures are loud, and the device's words are kept.** A Shield that refuses
the intent raises `intent_failed` carrying *its* message ("unable to resolve
Intent", "does not exist", "Permission Denial") — never a silent success. ADB
being off, unreachable, or answering nothing raises `intents_need_adb` /
`intent_no_answer`: the intents feature **requires ADB**, because the remote
protocol cannot carry an intent, and the error says so instead of failing
obscurely.
**An intent the app rejects is a configuration problem.** The app may no longer
handle that action, the component may have been renamed by an update, or the
intent may need a permission it does not have. The error shows the Shield's own
message and the fix is the registration — the integration never retries, guesses
or falls back to another intent.
**How the command is built (and why it is safe).** `am start -n <component>
-a <action> -c <category> -d <data> --es/--ei/--ez key value`, assembled from the
stored fields. `{placeholder}` substitution is plain text replacement — **no
`eval`, no `format()`, no shell interpolation** — and every value is quoted, so a
variable whose value is `"; rm -rf /"` reaches the Shield as literal data inside
one argument. This is deliberately **not** a second raw-shell door:
`nvidia_shield.adb_command` remains that one, and this action can only ever send
an intent you registered.
### Screen capture (opt-in, manual)
The integration can expose an `image` entity showing the Shield's current screen —