Files
James Mackay 259a6c29bd 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.
2026-10-12 12:54:41 +01:00
..