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 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 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 `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 Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live 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, integration: a documented, opt-in escape hatch for debugging and automation,
registered at setup and unregistered when the last participating entity unloads. 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) ### Screen capture (opt-in, manual)
The integration can expose an `image` entity showing the Shield's current screen — The integration can expose an `image` entity showing the Shield's current screen —
File diff suppressed because it is too large. Load diff
+143 -5
View File
@@ -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_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_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.", "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": { "abort": {
"already_configured": "This Shield is already set up.", "already_configured": "This Shield is already set up.",
@@ -192,7 +195,8 @@
"apps": "Apps", "apps": "Apps",
"discover": "Discover installed apps", "discover": "Discover installed apps",
"scripts": "Scripts", "scripts": "Scripts",
"finish": "Save and close" "finish": "Save and close",
"intents": "Intents (buttons and send_intent)"
} }
}, },
"connection": { "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_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." "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." "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": { "exceptions": {
@@ -355,8 +457,8 @@
"message": "The command field is empty — nothing was sent." "message": "The command field is empty — nothing was sent."
}, },
"unsupported_target": { "unsupported_target": {
"title": "Unsupported entity for adb_command", "title": "Unsupported entity for this action",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent." "message": "{entity_id} is not a participant of {service} — nothing was sent."
}, },
"adb_unavailable": { "adb_unavailable": {
"title": "ADB unavailable", "title": "ADB unavailable",
@@ -376,7 +478,7 @@
}, },
"ambiguous_target": { "ambiguous_target": {
"title": "More than one Shield selected", "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": { "unknown_activity": {
"title": "Unknown activity", "title": "Unknown activity",
@@ -389,6 +491,42 @@
"protocol_unavailable": { "protocol_unavailable": {
"title": "Remote-protocol transport 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." "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": { "entity": {
@@ -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_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_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.", "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": { "abort": {
"already_configured": "This Shield is already set up.", "already_configured": "This Shield is already set up.",
@@ -192,7 +195,8 @@
"apps": "Apps", "apps": "Apps",
"discover": "Discover installed apps", "discover": "Discover installed apps",
"scripts": "Scripts", "scripts": "Scripts",
"finish": "Save and close" "finish": "Save and close",
"intents": "Intents (buttons and send_intent)"
} }
}, },
"connection": { "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_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." "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." "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": { "exceptions": {
@@ -355,8 +457,8 @@
"message": "The command field is empty — nothing was sent." "message": "The command field is empty — nothing was sent."
}, },
"unsupported_target": { "unsupported_target": {
"title": "Unsupported entity for adb_command", "title": "Unsupported entity for this action",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent." "message": "{entity_id} is not a participant of {service} — nothing was sent."
}, },
"adb_unavailable": { "adb_unavailable": {
"title": "ADB unavailable", "title": "ADB unavailable",
@@ -376,7 +478,7 @@
}, },
"ambiguous_target": { "ambiguous_target": {
"title": "More than one Shield selected", "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": { "unknown_activity": {
"title": "Unknown activity", "title": "Unknown activity",
@@ -389,6 +491,42 @@
"protocol_unavailable": { "protocol_unavailable": {
"title": "Remote-protocol transport 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." "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": { "entity": {
+66 -15
View File
@@ -1844,36 +1844,87 @@ class TestIconOverride:
class TestServiceRegistryKey: class TestServiceRegistryKey:
"""t26 F4 + t29 G8: the participant key is computed once — the registry """t26 F4 + t29 G8: the participant key is computed once — the registry
add/remove always match (the service itself stays registered).""" add/remove always match (the service itself stays registered).
async def test_late_entity_id_assignment_still_removes(self, modules): t17: these drive the REAL hooks instead of hand-writing the registry. The
hand-written version passed while ``async_added_to_hass`` registered ``None``
import entity_stublib for a real entry (it filled the registry before consuming the entry's
from nsh.media_player import _ADB_COMMAND_TARGETS manager) and while ``async_will_remove_from_hass`` never dropped the key at
all — both invisible to a test that pokes the dict itself.
"""
@staticmethod
def _real_player(modules):
"""A player wired the way a real entry is: the manager on runtime_data."""
mods = load_entity_modules() mods = load_entity_modules()
player, _s, _i = make_player(mods) player, _s, _i = make_player(mods)
manager = player._manager
# exactly what __init__.py puts on the entry before the platforms load
player._entry.runtime_data = manager
player._entry.options["adb_command_enabled"] = True player._entry.options["adb_command_enabled"] = True
player._attr_unique_id = "uid_late" player._attr_unique_id = "uid_late"
# the harness constructs the entity without __init__, so the list the
# hook appends its tracked listeners to has to exist (`_power_entity` and
# `_jellyfin_entity` are read-only properties over the entry's options —
# with neither configured they are None, which is the real empty state)
player._power_sensors = {}
player._unsub_listeners = []
player._manager_unsub = None
# no entity_id yet at registration (HA core assigns it right after) # no entity_id yet at registration (HA core assigns it right after)
player.hass = entity_stublib.FakeHass() player.hass = entity_stublib.FakeHass()
player.hass.services = FakeServices() player.hass.services = FakeServices()
if hasattr(player, "_adb_command_key"): return player, manager
del player._adb_command_key # start clean
# add path — the key is computed once from unique_id async def test_late_entity_id_assignment_still_removes(self, modules):
key = getattr(player, "entity_id", None) or player._attr_unique_id from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS
player._adb_command_key = key
_ADB_COMMAND_TARGETS[key] = player._manager player, manager = self._real_player(modules)
assert "uid_late" in _ADB_COMMAND_TARGETS for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS):
registry.pop("uid_late", None)
await player.async_added_to_hass()
assert _ADB_COMMAND_TARGETS["uid_late"] is manager, "the KEY is the unique_id"
assert _INTENT_TARGETS["uid_late"] is manager
# HA core assigned the entity_id afterwards — removal still matches # HA core assigned the entity_id afterwards — removal still matches
player.entity_id = "media_player.shield_test" player.entity_id = "media_player.shield_test"
# drop participation via the stored key (entity_id is NOT consulted) await player.async_will_remove_from_hass()
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
assert "uid_late" not in _ADB_COMMAND_TARGETS assert "uid_late" not in _ADB_COMMAND_TARGETS, "the add key, not entity_id"
assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS
assert "uid_late" not in _INTENT_TARGETS, "both registries are dropped"
assert "media_player.shield_test" not in _INTENT_TARGETS
async def test_the_hook_registers_the_entry_s_own_manager(self, modules):
"""The manager comes from runtime_data — never None (t17 latent fix)."""
player, _manager_ignored = self._real_player(modules)
fresh = make_manager()
player._manager = None # __init__ has not run in this harness
player._entry.runtime_data = fresh
await player.async_added_to_hass()
from nsh.media_player import _ADB_COMMAND_TARGETS
assert _ADB_COMMAND_TARGETS["uid_late"] is fresh
_ADB_COMMAND_TARGETS.pop("uid_late", None)
async def test_intents_do_not_need_the_adb_command_opt_in(self, modules):
"""A registered intent is the user's own opt-in (t17)."""
from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS
player, manager = self._real_player(modules)
player._entry.options["adb_command_enabled"] = False # the raw door is off
for registry in (_ADB_COMMAND_TARGETS, _INTENT_TARGETS):
registry.pop("uid_late", None)
await player.async_added_to_hass()
assert "uid_late" not in _ADB_COMMAND_TARGETS
assert _INTENT_TARGETS["uid_late"] is manager
_INTENT_TARGETS.pop("uid_late", None)
async def test_setup_entry_object_carries_the_key(self): async def test_setup_entry_object_carries_the_key(self):
"""The participant object stores the key F4 computed (both hooks).""" """The participant object stores the key F4 computed (both hooks)."""
+17 -1
View File
@@ -1628,12 +1628,27 @@ OPTIONS_NESTED_STEPS = {
"apps_edit_form": "apps", "apps_edit_form": "apps",
"apps_remove": "apps", "apps_remove": "apps",
"apps_remove_confirm": "apps", "apps_remove_confirm": "apps",
# t17: the Intents editor is the same shape — a menu, then one row per step
"intents_edit": "intents",
"intents_form": "intents",
"intents_remove": "intents",
"intents_remove_confirm": "intents",
"intents_scan": "intents",
"intents_scan_result": "intents",
} }
#: Menu rows that ACT rather than render a form: HA dispatches them with no input, #: Menu rows that ACT rather than render a form: HA dispatches them with no input,
#: so there is no data_schema and no field labels to check. #: so there is no data_schema and no field labels to check.
OPTIONS_ACTION_ROWS = frozenset( OPTIONS_ACTION_ROWS = frozenset(
{"discover", "finish", "menu_again", "apps_remove_yes", "apps_remove_no"} {
"discover",
"finish",
"menu_again",
"apps_remove_yes",
"apps_remove_no",
"intents_remove_yes",
"intents_remove_no",
}
) )
@@ -2066,6 +2081,7 @@ class TestOptionsMenu:
"connection", "connection",
"receiver", "receiver",
"apps", "apps",
"intents",
"discover", "discover",
"scripts", "scripts",
"finish", "finish",
File diff suppressed because it is too large. Load diff