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 —
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1844,36 +1844,87 @@ class TestIconOverride:
|
||||
|
||||
class TestServiceRegistryKey:
|
||||
"""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):
|
||||
|
||||
import entity_stublib
|
||||
from nsh.media_player import _ADB_COMMAND_TARGETS
|
||||
t17: these drive the REAL hooks instead of hand-writing the registry. The
|
||||
hand-written version passed while ``async_added_to_hass`` registered ``None``
|
||||
for a real entry (it filled the registry before consuming the entry's
|
||||
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()
|
||||
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._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)
|
||||
player.hass = entity_stublib.FakeHass()
|
||||
player.hass.services = FakeServices()
|
||||
if hasattr(player, "_adb_command_key"):
|
||||
del player._adb_command_key # start clean
|
||||
return player, manager
|
||||
|
||||
# add path — the key is computed once from unique_id
|
||||
key = getattr(player, "entity_id", None) or player._attr_unique_id
|
||||
player._adb_command_key = key
|
||||
_ADB_COMMAND_TARGETS[key] = player._manager
|
||||
assert "uid_late" in _ADB_COMMAND_TARGETS
|
||||
async def test_late_entity_id_assignment_still_removes(self, modules):
|
||||
from nsh.media_player import _ADB_COMMAND_TARGETS, _INTENT_TARGETS
|
||||
|
||||
player, manager = self._real_player(modules)
|
||||
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
|
||||
player.entity_id = "media_player.shield_test"
|
||||
|
||||
# drop participation via the stored key (entity_id is NOT consulted)
|
||||
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
|
||||
assert "uid_late" not in _ADB_COMMAND_TARGETS
|
||||
await player.async_will_remove_from_hass()
|
||||
|
||||
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 "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):
|
||||
"""The participant object stores the key F4 computed (both hooks)."""
|
||||
|
||||
@@ -1628,12 +1628,27 @@ OPTIONS_NESTED_STEPS = {
|
||||
"apps_edit_form": "apps",
|
||||
"apps_remove": "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,
|
||||
#: so there is no data_schema and no field labels to check.
|
||||
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",
|
||||
"receiver",
|
||||
"apps",
|
||||
"intents",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user