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
|
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
@@ -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": {
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user