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:
1 parent
a8041e1428
commit
259a6c29bd
7 files changed
+2715
-27
No files matched your search
@@ -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 —
|
||||
|
||||
Reference in new issue
Block a user