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

@@ -123,7 +123,10 @@
"invalid_app_map": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again."
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.",
"invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.",
"unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.",
"invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja."
},
"abort": {
"already_configured": "This Shield is already set up.",
@@ -192,7 +195,8 @@
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close"
"finish": "Save and close",
"intents": "Intents (buttons and send_intent)"
}
},
"connection": {
@@ -330,6 +334,86 @@
"channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.",
"channel_send_enter": "Turn on if your channel app needs ENTER after the digits."
}
},
"intents": {
"title": "Intents",
"description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}",
"menu_options": {
"intents_edit": "Add or edit an intent",
"intents_remove": "Remove an intent",
"intents_scan": "Scan an app's intents",
"menu_again": "Back to the main menu"
}
},
"intents_edit": {
"title": "Add or edit an intent",
"description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart."
}
},
"intents_form": {
"title": "The intent",
"description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.",
"data": {
"intent_name": "Name",
"intent_package": "Package id",
"intent_activity": "Activity (component)",
"intent_action": "Action",
"intent_categories": "Categories",
"intent_data": "Data (URL template)",
"intent_fields": "Variables (declared fields)"
},
"data_description": {
"intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.",
"intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.",
"intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.",
"intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.",
"intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.",
"intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.",
"intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high."
}
},
"intents_remove": {
"title": "Remove an intent",
"description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intent to remove from this entry's options."
}
},
"intents_remove_confirm": {
"title": "Remove this intent?",
"description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.",
"menu_options": {
"intents_remove_yes": "Yes, remove it",
"intents_remove_no": "No, keep it"
}
},
"intents_scan": {
"title": "Scan an app's intents",
"description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}",
"data": {
"intent_scan_package": "App to scan"
},
"data_description": {
"intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id."
}
},
"intents_scan_result": {
"title": "Discovered intents",
"description": "{scan_note}",
"data": {
"intent_candidate": "Intent to register"
},
"data_description": {
"intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed."
}
}
}
},
@@ -347,6 +431,24 @@
"description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
},
"send_intent": {
"name": "Send intent",
"description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.",
"fields": {
"intent": {
"name": "Intent",
"description": "Which registered intent to send, by the name the Intents screen shows."
},
"variables": {
"name": "Variables",
"description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused."
},
"entity_id": {
"name": "Entity",
"description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
}
},
"exceptions": {
@@ -355,8 +457,8 @@
"message": "The command field is empty — nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent."
"title": "Unsupported entity for this action",
"message": "{entity_id} is not a participant of {service} — nothing was sent."
},
"adb_unavailable": {
"title": "ADB unavailable",
@@ -376,7 +478,7 @@
},
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one adb_command target — select exactly one Shield (one media_player entity) — command not sent."
"message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent."
},
"unknown_activity": {
"title": "Unknown activity",
@@ -389,6 +491,42 @@
"protocol_unavailable": {
"title": "Remote-protocol transport unavailable",
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
},
"unknown_intent": {
"title": "Unknown intent",
"message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents."
},
"missing_intent_field": {
"title": "Missing variable",
"message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent."
},
"unknown_intent_variable": {
"title": "Undeclared variable",
"message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables."
},
"invalid_intent_field": {
"title": "Wrong value type",
"message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent."
},
"invalid_intent_variables": {
"title": "Variables must be a mapping",
"message": "'variables' must map field names to values for '{intent}' — nothing was sent."
},
"unresolved_intent_placeholder": {
"title": "Unfilled placeholder",
"message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent."
},
"intents_need_adb": {
"title": "ADB is required for intents",
"message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent."
},
"intent_no_answer": {
"title": "The Shield did not answer",
"message": "The Shield did not answer for {entity_id} — the intent was not sent."
},
"intent_failed": {
"title": "The Shield refused the intent",
"message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have."
}
},
"entity": {