Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab185ce50 | ||
|
|
400f0327af | ||
|
|
7ed982801a | ||
|
|
de1c34777d | ||
|
|
8f064b3247 | ||
|
|
6b866019ef | ||
|
|
66846b2f82 | ||
|
|
e6c6bdb1e6 | ||
|
|
259a6c29bd | ||
|
|
a8041e1428 | ||
|
|
2819ee5a49 | ||
|
|
91d9fb3883 | ||
|
|
14c168c25e | ||
|
|
6aa3b6bfdd | ||
|
|
8a4e72b80a | ||
|
|
7b32de789a | ||
|
|
f29f62ddae | ||
|
|
a1ce28996e | ||
|
|
46738063aa | ||
|
|
eb62011ded |
No files matched your search
+43
-19
@@ -114,24 +114,34 @@ jobs:
|
||||
- name: HA API-drift sweep
|
||||
run: python3 -m pytest tests/test_ha_api_drift.py -q
|
||||
|
||||
# The real-HA config-flow tests (tests/test_config_flow_real_ha.py) are the
|
||||
# ONLY place the config flow meets the real homeassistant.data_entry_flow
|
||||
# machinery — and that file SKIPS ITSELF at import time when homeassistant is
|
||||
# absent (pytest.importorskip at module scope). A skipped file and a passing
|
||||
# one are indistinguishable in the jobs above: `python3 -m pytest tests -q`
|
||||
# ends in "N passed, 1 skipped" and still exits 0, and the drift sweep never
|
||||
# touches this file. That is exactly how three real-HA-only facts stayed
|
||||
# hidden while five gates were green:
|
||||
# The real-HA tests are the ONLY place the integration meets the installed Home
|
||||
# Assistant — and EVERY one of them SKIPS ITSELF at import time when
|
||||
# homeassistant is absent (pytest.importorskip at module scope). A skipped file
|
||||
# and a passing one are indistinguishable in the jobs above: `python3 -m pytest
|
||||
# tests -q` ends in "N passed, 3 skipped" and still exits 0, and the drift sweep
|
||||
# never touches them. That is exactly how three real-HA-only facts stayed hidden
|
||||
# while five gates were green:
|
||||
#
|
||||
# * a menu row id must equal its async_step_<id> method name (otherwise the
|
||||
# real FlowManager raises when the row is dispatched),
|
||||
# * the rendered step_id must equal the row id, and
|
||||
# * async_show_menu takes no errors kwarg.
|
||||
#
|
||||
# t14 added a second file, and with it the reason this job GLOBS instead of
|
||||
# naming one: tests/test_adb_command_real_ha.py proves the adb_command action is
|
||||
# SELECTABLE (the runtime service description carries the entity target the
|
||||
# frontend builds its picker from — `async_register` has no target parameter) and
|
||||
# that a real async_call returns the command's output, and it also caught an
|
||||
# `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry
|
||||
# method. A named file list would have left it skipping silently here. The
|
||||
# pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up
|
||||
# by virtue of existing, and the files that ran are printed.
|
||||
#
|
||||
# So this job does two things the others cannot: it installs the real stack on
|
||||
# Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are
|
||||
# skipped instead of run. Do not "simplify" the guard at the bottom away — a
|
||||
# test file that skips silently is indistinguishable from one that passes.
|
||||
# skipped instead of run (or if the pattern matches nothing). Do not "simplify"
|
||||
# the guard at the bottom away — a test file that skips silently is
|
||||
# indistinguishable from one that passes.
|
||||
real-ha-config-flow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -180,40 +190,54 @@ jobs:
|
||||
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
|
||||
PY
|
||||
|
||||
- name: Real-HA config-flow tests
|
||||
- name: Real-HA tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG=/tmp/real-ha-config-flow.log
|
||||
LOG=/tmp/real-ha.log
|
||||
# THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new
|
||||
# real-HA file cannot skip silently merely because nobody listed it here.
|
||||
# An empty match is a failure too — but it is checked first so the log says
|
||||
# why instead of blaming pytest's argument parsing.
|
||||
shopt -s nullglob
|
||||
FILES=(tests/test_*real_ha.py)
|
||||
shopt -u nullglob
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously"
|
||||
exit 1
|
||||
fi
|
||||
echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}"
|
||||
# Verbatim the command this gate is validated with locally; -rs is what
|
||||
# makes a silent skip visible in the log.
|
||||
set +e
|
||||
uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
|
||||
--with pytest-asyncio \
|
||||
pytest tests/test_config_flow_real_ha.py -q -rs 2>&1 | tee "$LOG"
|
||||
pytest "${FILES[@]}" -q -rs 2>&1 | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::the real-HA config-flow tests exited $rc"
|
||||
echo "::error::the real-HA tests exited $rc"
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
# THE GUARD. Some pytest versions exit 0 for a file that skipped
|
||||
# itself, so the exit code above is not enough. The last line of a -q
|
||||
# run is the counts line ("26 passed in 1.6s" / "1 skipped in
|
||||
# 0.01s"); it must report PASSED tests and nothing else.
|
||||
# run is the counts line ("50 passed in 1.6s" / "1 skipped in
|
||||
# 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and
|
||||
# nothing else — a skip, a failure and a zero-collection run all fail
|
||||
# here, which is what makes the glob safe to widen.
|
||||
summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1)
|
||||
echo "pytest summary: $summary"
|
||||
case "$summary" in
|
||||
[0-9]*" passed"*) ;;
|
||||
*)
|
||||
echo "::error::the real-HA config-flow tests did not run (summary: $summary)"
|
||||
echo "The file skips itself when homeassistant/probatio is missing — a skip here is a FAILURE, not a pass."
|
||||
echo "::error::the real-HA tests did not run (summary: $summary)"
|
||||
echo "These files skip themselves when homeassistant is missing — a skip here is a FAILURE, not a pass."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$summary" in
|
||||
*skipped*|*failed*|*error*|*"no tests ran"*)
|
||||
echo "::error::the real-HA config-flow run was not a clean pass (summary: $summary)"
|
||||
echo "::error::the real-HA run was not a clean pass (summary: $summary)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -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
|
||||
@@ -162,14 +187,187 @@ the contractual path (YAML is the seed, the flow is the truth).
|
||||
### Raw `adb_command` service (opt-in)
|
||||
|
||||
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||
off**), the integration registers the `nvidia_shield.adb_command` service:
|
||||
send a raw ADB shell command to the entry's persistent connection
|
||||
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
|
||||
entity is optional when exactly one entry provides the service). 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. It
|
||||
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||||
off**), the integration registers the `nvidia_shield.adb_command` service: send a
|
||||
raw ADB shell command to the entry's persistent connection.
|
||||
|
||||
**Picking the Shield in the UI.** The action declares a target, so **Developer
|
||||
tools → Actions** (and the automation editor) offers a target picker whose entity
|
||||
list contains the Shield's `media_player` entity — the same way the core
|
||||
`androidtv` integration's action worked. Select the Shield there, type the
|
||||
command, and call it.
|
||||
Home Assistant renders that picker from the action's *target description*, which
|
||||
the integration publishes at setup — that is why the Shield is selectable at all.
|
||||
|
||||
**As YAML / automation data**, the same call:
|
||||
|
||||
```yaml
|
||||
- action: nvidia_shield.adb_command
|
||||
target:
|
||||
entity_id: media_player.shield
|
||||
data:
|
||||
command: getprop ro.product.model
|
||||
response_variable: adb # optional: keep the output in a variable
|
||||
```
|
||||
|
||||
- `command` (**required**) — the shell command. An empty one is an error
|
||||
(`invalid_command`), never a silent no-op.
|
||||
- The target (**required**) is the Shield's `media_player` entity, or its
|
||||
`device_id` (`target: {device_id: …}` / `entity_id: …`). There is **no
|
||||
"pick the only one" fallback**: with no target the call fails with `no_target`,
|
||||
two targets fail with `ambiguous_target`, and an entity that does not participate
|
||||
(its entry has the option off) fails with `unsupported_target` — a raw shell
|
||||
command is never sent to a Shield you did not select.
|
||||
- **The output comes back** in the response under `adb_response` — the same key the
|
||||
deprecated `androidtv.adb_command` action returned, so a template written against
|
||||
that integration keeps reading the same field:
|
||||
|
||||
```yaml
|
||||
- action: nvidia_shield.adb_command
|
||||
target: {entity_id: media_player.shield}
|
||||
data: {command: "getprop ro.product.model"}
|
||||
response_variable: adb
|
||||
- action: notify.persistent_notification
|
||||
data:
|
||||
message: "{{ adb.adb_response }}"
|
||||
```
|
||||
|
||||
A caller that does not ask for a response is unchanged (the response is
|
||||
*optional*, not required). If ADB is down the action raises `adb_unavailable` —
|
||||
an error, never an empty response; a transport failure mid-command propagates as
|
||||
an error too.
|
||||
|
||||
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 —
|
||||
`sensor`-free and **off by default**. Turn it on in **Configure → Main setup →
|
||||
"Add a screen capture image entity"**; the entity appears as
|
||||
`image.<entry>_screen_capture` on the existing device.
|
||||
|
||||
Three things about it are deliberate, and they are the whole design:
|
||||
|
||||
- **It never drives state.** An `image` entity's state is the timestamp of its last
|
||||
successful capture. Nothing about a capture writes to the media player, the
|
||||
sensors, the power switch or the manager's store — the capture path reads the ADB
|
||||
poller and nothing else. Your media player keeps saying what the DEVICE reported,
|
||||
not what a screenshot happened to show.
|
||||
- **It never polls.** HA sets `should_poll = False` for image entities and this one
|
||||
keeps it: no scan interval, no timer. A capture happens only when something asks
|
||||
for the bytes (the dashboard showing the entity picture) or when you call
|
||||
`homeassistant.update_entity` on it. Back-to-back requests are coalesced into one
|
||||
capture and a successful capture is reused for 5 seconds, so a dashboard repaint
|
||||
cannot hammer the Shield.
|
||||
- **What it costs.** Every fresh capture is a full-resolution screen grab
|
||||
(`screencap -p`) of the current display, base64-encoded, pulled over the same ADB
|
||||
socket the state polling uses, and it keeps that socket busy for the duration. On
|
||||
a 1080p UI that is typically a few hundred kilobytes of PNG (roughly a third more
|
||||
on the wire because of the base64). Treat it as a diagnostic surface you look at
|
||||
when something looks wrong, not as a camera.
|
||||
|
||||
It is unavailable — never a blank image — whenever ADB is off, ADB is down, or the
|
||||
last capture failed; the `capture_status` attribute says which (`ok`,
|
||||
`adb_disabled`, `adb_disconnected`, `capture_failed`). An explicit
|
||||
`homeassistant.update_entity` retries after a failure.
|
||||
|
||||
**How it captures, and why that matters.** The transport is `screencap -p |
|
||||
base64` over the existing shell, decoded locally with `base64.b64decode` and
|
||||
checked for the PNG signature and the `IHDR` chunk before anything is handed to HA.
|
||||
The alternative — writing a temp PNG on the device and pulling it as a file — needs
|
||||
the raw ADB device object and writes to the Shield's shared storage, where a crash
|
||||
between write and delete leaves a screenshot behind; the base64 route leaves no
|
||||
device-side artifact at all. The cost is the extra ~33% on the wire, and one thing
|
||||
is **inferred rather than measured**: no Shield was available in the development
|
||||
environment, so how the persistent socket copes with a few hundred kilobytes in one
|
||||
`shell()` call has not been observed on hardware. The decode, the failure paths and
|
||||
the entity contract are covered by tests, including a real-HA run that drives HA's
|
||||
own `ImageEntity` and content-type validator.
|
||||
|
||||
|
||||
## Replacing androidtv / androidtv_remote + the glue
|
||||
|
||||
@@ -198,7 +396,7 @@ What each retired piece used to provide, and what covers it now:
|
||||
| Retired piece | Covered by |
|
||||
|---------------|------------|
|
||||
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the one app-name chain: your apps map, then the device's own label for the app on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss), then the built-in app names, then the package id |
|
||||
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
|
||||
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
|
||||
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
|
||||
@@ -310,9 +508,9 @@ and only **Save and close** commits:
|
||||
|
||||
| Row | What it holds |
|
||||
| --- | --- |
|
||||
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch |
|
||||
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, `Add a screen capture image entity` (off by default — see [Screen capture](#screen-capture-opt-in-manual)), Jellyfin player, power switch |
|
||||
| **Receiver** | the receiver/soundbar entity and the input it uses |
|
||||
| **Apps** | the apps table — one row per app (see below) |
|
||||
| **Apps** | the apps editor — add, edit or remove one app, or paste the whole list (see below) |
|
||||
| **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER |
|
||||
| **Save and close** | writes the merged options and reloads the integration |
|
||||
|
||||
@@ -329,9 +527,44 @@ already submitted. The two pairing rows appear only while that transport still n
|
||||
pairing (a skip leaves its row there to retry; a successful pairing removes it), and
|
||||
the menu's description names what is still to pair.
|
||||
|
||||
Turning **Add the Nvidia Remote Protocol** on in the **Main setup** row of the
|
||||
*options* page does not silently enable a client that cannot connect: an entry that has
|
||||
never completed a pairing is taken to the same **Pair the remote protocol** step, and
|
||||
its **Continue without pairing** escape turns the option back off. Pairing is remembered
|
||||
per entry, so switching the transport off and on again on a paired entry asks for no new
|
||||
PIN (the certificate from the first pairing is reused — see
|
||||
[is my transport paired?](#is-my-protocol-transport-paired-connected-or-not-running-at-all)).
|
||||
|
||||
### The Apps table
|
||||
|
||||
The **Apps** row is an editable table. One app per line, in the documented format:
|
||||
The **Apps** row opens a small editor rather than one long form. Home Assistant has no
|
||||
*table* selector, so a table is edited the way HA edits one — **one row per step**:
|
||||
|
||||
| Row | What it does |
|
||||
| --- | --- |
|
||||
| **Edit the whole list as text** | the multiline field below: paste, rename or delete rows in one go |
|
||||
| **Add an app** | package id, name to show, and an optional icon |
|
||||
| **Edit an app** | pick one of the apps the Shield reported, or one you saved; its name and icon open prefilled |
|
||||
| **Remove an app** | pick a saved row, then confirm it on the next step |
|
||||
| **Back to the main menu** | leave the editor (nothing is written until **Save and close**) |
|
||||
|
||||
*Edit* and *Remove* are offered only while there is something to act on: a table with
|
||||
no remembered rows and no discovery yet shows just the text field and *Add an app*. The
|
||||
editor's description lists every app it knows — the rows you saved, and the ones
|
||||
discovery found, marked `[detected]`. **Edit** acts on both (that is the point: an app
|
||||
the device does not name reads as `Your Name (package id)` when the map or the built-in
|
||||
app names know it, and as `package id (no name yet)` when nothing does — picking it is how
|
||||
you give it a name, and saving stores it as one of your own rows). **Remove** acts only on
|
||||
rows you saved, because a detected app has no line to delete (it would simply come back
|
||||
with the next sweep).
|
||||
|
||||
A discovered app that nothing can name is **never pre-filled into the text field** as
|
||||
`package=package`: a row like that would store the package id as if it were a name. It is
|
||||
offered in the row editor instead, where "no name yet" says what it is.
|
||||
Removing a row never uninstalls anything from the Shield — the app simply stops being
|
||||
offered. Rows you do not touch are left exactly as they were.
|
||||
|
||||
The text field is one app per line, in the same format the integration stores:
|
||||
|
||||
```
|
||||
ar.tvplayer.tv=TiviMate
|
||||
@@ -340,16 +573,14 @@ com.netflix.ninja=Netflix
|
||||
```
|
||||
|
||||
`package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or
|
||||
a `camera.*` entity id — see *Sources* below). The field always opens carrying the
|
||||
current rows, so adding, renaming or deleting an app is an edit to that text:
|
||||
|
||||
- **add a row** — type a new line
|
||||
- **edit a row** — change its name, icon or package id
|
||||
- **remove a row** — delete its line
|
||||
a `camera.*` entity id — see *Sources* below). The field opens carrying the current
|
||||
rows, and it is a **multiline** input: earlier versions rendered it as a single-line
|
||||
string, so the whole table arrived as one flattened line with nowhere to add a row.
|
||||
|
||||
A blank field clears the table (and the integration falls back to its built-in app
|
||||
list). A malformed row — no `=`, or an empty name — is rejected with an error on the
|
||||
page rather than being half-saved.
|
||||
list). A malformed row — no `=`, an empty name, a space in the package id or in the
|
||||
icon — is rejected with an error on the page rather than being half-saved, and the
|
||||
*Add* and *Edit* forms enforce exactly the same rule.
|
||||
|
||||
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
|
||||
speaker that only knows on and off is a valid receiver. Playback then **ensures
|
||||
@@ -442,8 +673,10 @@ outcome that leaves you guessing.
|
||||
### App discovery (what is actually installed)
|
||||
|
||||
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
|
||||
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu
|
||||
and the table is rebuilt from two ADB queries:
|
||||
start from the built-in app names alone. Pick **Discover installed apps** on the Configure
|
||||
menu and the table is rebuilt from two ADB queries — and the row then **says what happened**
|
||||
(see *What the Discover row tells you* below), because an action that appears to do nothing
|
||||
is indistinguishable from a broken integration:
|
||||
|
||||
| Command | What it returns | How it is treated |
|
||||
| --- | --- | --- |
|
||||
@@ -461,15 +694,44 @@ capture: no real Shield was available when this was written, so
|
||||
fixtures — the same "inferred, not observed" mark the state=6 buffering value
|
||||
carries in the source ladder above.)*
|
||||
|
||||
Names come from the same cached path the Current App sensor uses: a `pm dump` per
|
||||
package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps
|
||||
in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in
|
||||
seconds instead of the minutes a strictly serialised sweep would take; the commands
|
||||
share the watchdog's own ADB connection, and `adb-shell` serialises the I/O on it, so
|
||||
the burst queues politely instead of interleaving on the socket. A label fetch
|
||||
that **fails is not cached**, so that app shows its package id in this table and the
|
||||
next sweep tries again — this is the same rule as the entity path, and it is why a
|
||||
transient ADB hiccup no longer sticks.
|
||||
Names come from the same cached path the Current App sensor uses, and there is ONE
|
||||
chain behind every surface — this table, `media_player.app_name`/`source`, the Current
|
||||
App sensor and the Apps editor. In order, the first answer wins:
|
||||
|
||||
1. **your Apps map** — your own edit always wins;
|
||||
2. **the device's own label** for that app, read from `pm dump <package>`: the probe
|
||||
greps **on the device** for the label shapes Android prints — `application-label:`,
|
||||
`nonLocalizedLabel=` and `labelRes=` — so only those lines cross ADB (a real dump on
|
||||
one reported Shield is ~2241 lines of dumpsys, and the old `head -300` cut it before
|
||||
the package section even began, which is why it looked like "no labels");
|
||||
a quoted value counts, `null` and a resource id like `0x7f0f0d31` do not;
|
||||
3. **the built-in app names** (see *Sources* below) — a curated, community-maintained
|
||||
`package → name` table;
|
||||
4. **the package id**, shown **once** (never as `pkg=pkg` in a row).
|
||||
|
||||
A label probe is cached for 24 h, and its outcome is remembered either way: a probe that
|
||||
**ran** and found nothing is a definitive negative (zero further round trips for that
|
||||
app), while a probe that **could not run** is not cached at all and is retried on the next
|
||||
poll — this is the same rule as the entity path, and it is why a transient ADB hiccup no
|
||||
longer sticks.
|
||||
|
||||
A full sweep runs a handful of probes at a time (4 at a time, 50 ms apart), which
|
||||
finishes a typical Shield in seconds instead of the minutes a strictly serialised sweep
|
||||
would take; the commands share the watchdog's own ADB connection, and `adb-shell`
|
||||
serialises the I/O on it, so the burst queues politely instead of interleaving on the
|
||||
socket.
|
||||
|
||||
**Some devices expose no labels at all.** The reported Shield is one: nothing in that
|
||||
2241-line dump names any app. So after 5 probes in a row come back "ran, no label" the
|
||||
integration stops spending a shell per package for the rest of the 24 h TTL: the sweep
|
||||
answers the remaining packages from the map instead of probing them, names come from
|
||||
tiers 1, 3 and 4, and a later device change or update is re-checked when the TTL
|
||||
expires. The Configure menu says so in its verdict when it happens, and the table's
|
||||
`name` is then your map's name, the built-in name, or **empty** — an app nothing can name
|
||||
keeps the id in `package` alone rather than inventing a name out of it.
|
||||
|
||||
An app that nothing names is still offered by the **Apps** row editor (it reads
|
||||
"`com.example.app` (no name yet)"), which is where you give it one.
|
||||
|
||||
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
|
||||
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
|
||||
@@ -486,6 +748,22 @@ the menu says why. A sweep that **could not run** is not cached as an answer: it
|
||||
recorded as `unknown` and retried, so a Shield that was asleep at startup is picked up
|
||||
by the next refresh instead of reporting an empty device for six hours.
|
||||
|
||||
#### What the Discover row tells you
|
||||
|
||||
The Discover row is an *action*: it runs a sweep and returns you to the same menu with a
|
||||
one-line verdict, which replaces the usual "not discovered yet" note:
|
||||
|
||||
| Verdict | What it means |
|
||||
| --- | --- |
|
||||
| `N apps found on the Shield — the table is updated.` | the sweep ran, and the editor's text field now carries the discovered rows |
|
||||
| `the Shield reported no installed apps — the table keeps your saved rows.` | the sweep ran and found nothing to add; your rows are untouched |
|
||||
| `N apps found — this Shield exposes no app labels over ADB, so names come from the built-in app names and your own rows.` | the sweep ran, and the device itself named nothing: the per-package probes have stopped (see *App discovery* above) |
|
||||
| `the discovery sweep could not reach the Shield — check the ADB connection; your saved rows are unchanged.` | the sweep failed; nothing was cached as a result and nothing you wrote was lost |
|
||||
| `ADB is not set up for this entry — nothing could be discovered.` | this entry has no ADB manager (a remote-protocol-only setup), so there is nothing to sweep |
|
||||
|
||||
The action is best-effort by design: whatever the verdict, the Configure menu still opens
|
||||
and the editor still works on the rows you wrote.
|
||||
|
||||
**For dashboards** the same table is a sensor: `sensor.<name>_installed_apps`, whose
|
||||
state is the app **count** and whose `installed_apps` attribute is the list of
|
||||
`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `unknown`,
|
||||
@@ -505,11 +783,20 @@ sweep could not run), while the Configure row forces one.
|
||||
### Sources (the app list in a dropdown)
|
||||
|
||||
The media player's source list is your **Apps** map — the same map app-name resolution
|
||||
uses. With no map configured it falls back to a small built-in list
|
||||
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||
something to show. Discovery fills the **Configure** table, not this fallback: the
|
||||
dropdown stays exactly the map you saved, so what a card offers is always what you
|
||||
chose. An app that discovery found and you want in the dropdown is one row to add.
|
||||
uses. With no map configured it falls back to the built-in app names, which on a device
|
||||
that exposes no labels *is* the naming: the table covers the apps the reported Shield has
|
||||
(Projectivy Launcher, Samba (SMB), X-plore, Plex Media Server, TiviMate, Hyperion Grabber,
|
||||
Prime Video, MediaSession2MQTT, Speech Services by Google, Button Mapper, Wholphin,
|
||||
Findroid, Pluto TV, YouTube, Downloader, TV Quick Actions, Argosy, Netflix, RetroArch and
|
||||
the Google TV launcher, plus Google's speech services), so a card shows
|
||||
"Netflix" instead of `com.netflix.ninja` with nothing configured at all.
|
||||
|
||||
That table is a **community-maintained convenience, not device truth**: package ids are
|
||||
stable, but display names change with vendors and app updates, and an app it has never
|
||||
heard of falls through to its package id. Your own map overrides any row in it — one line
|
||||
in **Apps** — and discovery fills the **Configure** table, not this fallback, so the
|
||||
dropdown stays exactly what you saved. An app that discovery found and you want in the
|
||||
dropdown is one row to add.
|
||||
|
||||
**Apps are sources, not browser media.** This integration no longer publishes the app
|
||||
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
|
||||
@@ -527,9 +814,11 @@ entity: media_player.living_room_shield
|
||||
```
|
||||
|
||||
- **`source_list`** — the app names. They come from the map (in the order it lists
|
||||
them), so editing the map renames the dropdown entries.
|
||||
- **`source`** — the app currently on screen, resolved exactly like the Current App
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
them), so editing the map renames the dropdown entries; with no map, the built-in
|
||||
app names above.
|
||||
- **`source`** — the app currently on screen, resolved by the same chain the Current App
|
||||
sensor uses: your map first, then the cached device label, then the built-in app name,
|
||||
then the raw package id.
|
||||
- **`media_player.select_source`** — picks one **by name** and launches it over the
|
||||
protocol, the same path `play_media` uses for apps. A raw package id works too (that
|
||||
holds the package id), names match case-insensitively, and a name that
|
||||
@@ -557,7 +846,8 @@ The map is your own override, so two shapes are accepted rather than papered ove
|
||||
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||
dropdown, but `app_name`, `source` and the Current App sensor
|
||||
display the map value **verbatim** — so `source` can be a string that is not in
|
||||
`source_list`. A blank value falls through to the raw package instead.
|
||||
`source_list`. A blank value falls through the rest of the chain instead: the device
|
||||
label, then the built-in app name, then the raw package id.
|
||||
|
||||
### Remote command script
|
||||
|
||||
@@ -593,14 +883,161 @@ All four variables are always present, including their defaults, so a script can
|
||||
on the shape. Unset — the default — nothing changes: keys go out over the protocol
|
||||
exactly as before.
|
||||
|
||||
### The remote entity: the app / activity picker
|
||||
|
||||
The `remote` entity speaks Home Assistant's own remote contract, matching the
|
||||
`androidtv_remote` entity it replaces — so a replaced setup keeps its automations
|
||||
**and** gets the app picker the frontend only renders for a remote that declares it:
|
||||
|
||||
| Piece | Value |
|
||||
| --- | --- |
|
||||
| `supported_features` | includes `RemoteEntityFeature.ACTIVITY` — without that flag Home Assistant offers **no activity picker at all**, which is why the remote card can look empty |
|
||||
| `activity_list` | the friendly app names, in your order: your **Apps table** (or the built-in table when you have none), plus the apps **ADB discovery** actually found on the device |
|
||||
| `current_activity` | the app on screen as a friendly name: the protocol's current-app push when it arrives, the ADB foreground reading otherwise. `None` while nothing is known — no activity is ever invented |
|
||||
| `turn_on` with `activity:` | wakes the Shield when it reports itself off, then launches that app over the protocol |
|
||||
|
||||
```yaml
|
||||
# launch Netflix by the name from the activity list / Apps table
|
||||
action: remote.turn_on
|
||||
target:
|
||||
entity_id: remote.living_room
|
||||
data:
|
||||
activity: Netflix
|
||||
```
|
||||
|
||||
An `activity` resolves in this order: a **name** from the activity list
|
||||
(case-insensitive), then a **package id** or a **detail link** (`https://…`,
|
||||
`market://…`) passed through unchanged — that is how an app your table does not carry
|
||||
is still launchable, and what the other integration does with a name it does not know.
|
||||
Anything else raises a clear error naming the value instead of silently doing nothing.
|
||||
Leaving `activity` out (or empty) keeps the old behaviour exactly: wake through the
|
||||
configured script, else `POWER` when the device reports it is off.
|
||||
|
||||
**Where the list comes from, and why.** `androidtvremote2` exposes **no app-list API
|
||||
at all** (measured against the installed library) — which is exactly why
|
||||
`androidtv_remote` keeps a user-maintained app map. This integration has the same kind
|
||||
of source *plus* something the other one lacks: ADB discovery, so the picker can offer
|
||||
what is actually installed and name it through the one chain (your Apps table → the
|
||||
device's own label → the built-in table → the package id). Apps nothing can name are
|
||||
not listed as bare package ids; pass their id to `activity` if you need them.
|
||||
|
||||
#### Is my protocol transport paired, connected, or not running at all?
|
||||
|
||||
The library offers no `is_paired` / `is_connected`, so the remote entity publishes what
|
||||
can be **proven** (Developer Tools > States, or
|
||||
`state_attr('remote.living_room', 'protocol_state')`):
|
||||
|
||||
| Attribute | Meaning |
|
||||
| --- | --- |
|
||||
| `protocol_state` | `off` (no transport configured), `never_connected`, `connected`, `disconnected`, or `auth_rejected` |
|
||||
| `protocol_connected` | `true` only while the device reports the protocol session up |
|
||||
| `protocol_ever_authenticated` | `true` once a session connected **or any push arrived** — the only proof of a paired client the library gives |
|
||||
| `protocol_current_app_seen` / `protocol_is_on_seen` / `protocol_volume_seen` | whether each device push has **ever** arrived (sticky: the state cache expires, these do not) |
|
||||
| `protocol_last_error` / `protocol_failures` | the last protocol connect/send failure verbatim, and how many sends could **not** run |
|
||||
| `protocol_certificate` | `present` / `missing` — the certificate from your setup pairing, which is why enabling the transport on a paired entry asks for **no new PIN** |
|
||||
| `power_on_route` / `power_off_route` | `protocol`, or `script:<script>` when a command script owns that command |
|
||||
| `activity_source` (and the media player's `app_source`) | which tier produced the app identity: `protocol` (the device's own push) or `adb` (the dumpsys parse) — the push is preferred, and it is never a silent override |
|
||||
|
||||
Reading it:
|
||||
|
||||
* **`auth_rejected`** — the device refused our certificate (a factory reset, or a
|
||||
deleted `.storage/nvidia_shield/client.pem`). Home Assistant also flags the entry
|
||||
for reauthentication; that flow re-pairs.
|
||||
* **`never_connected`** while the transport is enabled — switched on but with no
|
||||
session. Pairing again is the answer (the options flow routes an unpaired entry
|
||||
straight into the pairing step rather than enabling a client that cannot connect).
|
||||
* **`off`** — the transport is not configured. Everything else here still works: ADB
|
||||
is the state source, so an ADB-only setup degrades **only** the protocol surface.
|
||||
* A send that could not run is **never silent**: it is counted, recorded verbatim in
|
||||
`protocol_last_error`, and logged at WARNING together with `protocol_state`.
|
||||
|
||||
**The other Android TV integration.** `androidtv_remote` pairs **its own client** and
|
||||
shares nothing with us: its certificate lives in its own storage and its entities are
|
||||
its own. Ours (`client.pem` under `.storage/nvidia_shield/`) is reused between pairing
|
||||
and runtime by design, so *not* being asked for a PIN on an already-paired entry is
|
||||
expected — and it says nothing about whether the session is up. `protocol_state`
|
||||
answers that.
|
||||
|
||||
**A configured script bypasses the protocol.** When a command script owns `turn_on`
|
||||
(or `turn_off`), that script runs and the protocol is **never touched** — so a power
|
||||
press that worked through it is *not* evidence that the transport is paired, or even
|
||||
connected (`power_on_route` reads `script:…`). A configured **Remote command script**
|
||||
is the same story for `remote.send_command`. Only a command that went out over the
|
||||
protocol, with `protocol_state` = `connected`, exercises the transport.
|
||||
|
||||
## Entities
|
||||
|
||||
| Entity | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||
| Media Player | `media_player` | Main entity with playback controls, metadata and the flat `now_playing_*` attributes (below) |
|
||||
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
|
||||
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
|
||||
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string, with its own flat attributes (below) |
|
||||
| Remote | `remote` | Key commands, the activity/app picker, and the transport-state attributes (see [the activity picker](#the-remote-entity-the-app--activity-picker)) |
|
||||
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||
| Screen capture (`image.screen_capture`) | `image` | The Shield's current screen — **opt-in, off by default**, manual/diagnostic only (see [Screen capture](#screen-capture-opt-in-manual)) |
|
||||
|
||||
### Now-playing attributes
|
||||
|
||||
**Since t65 the media player's now-playing reading is a set of separate
|
||||
TOP-LEVEL attributes**, not one nested object — that object read badly in YAML:
|
||||
|
||||
| Attribute | What it holds |
|
||||
| --- | --- |
|
||||
| `now_playing_via` | which TIER produced the reading: `jellyfin`, `media_session` or `app` |
|
||||
| `now_playing_state` | the reading's own state — exactly one of `playing`, `paused`, `buffering`, `idle` |
|
||||
| `now_playing_title` | the title — or, in privacy mode, the app identity |
|
||||
| `now_playing_artist`, `now_playing_album` | the session's artist and album, when it has them |
|
||||
| `now_playing_media_type` | the content type the source reported |
|
||||
| `now_playing_series`, `now_playing_season`, `now_playing_episode` | series metadata, when the source reports it |
|
||||
| `now_playing_duration`, `now_playing_position`, `now_playing_position_updated_at` | playback timing |
|
||||
| `now_playing_thumbnail` | the artwork URL (or the Jellyfin entity picture) |
|
||||
| `now_playing_media_info` | the Jellyfin media-info dict, when that entity has one |
|
||||
|
||||
The rules that keep the set predictable:
|
||||
|
||||
- **Only what the tier answered is published.** An unknown field is OMITTED, never
|
||||
`null`, so nothing ever reads as `now_playing_title: null`.
|
||||
- **A device that reads `off` publishes none of them.** A Jellyfin session that has
|
||||
gone stale must not put a title beside a device that is off, and the state
|
||||
already says nothing is playing. `on` (no source reported a power or playback
|
||||
fact) publishes none either — there is no reading to publish.
|
||||
- **`now_playing_state` is one of FOUR values, and `on` is not one of them.** The
|
||||
full set is `playing`, `paused`, `buffering`, `idle` — the states a source can
|
||||
report when it has something to say. The row is published only while the entity
|
||||
state is one of those four, so `on` (nothing reported), `off` and any frontend-
|
||||
or app-side state can never appear beside a title. The list is code
|
||||
(`NOW_PLAYING_STATE_VALUES` in `media_player.py`) and a test compares THIS
|
||||
paragraph and the table above against it, so neither can drift.
|
||||
- **`now_playing_via` is deliberately not `source`.** On a media player, `source`
|
||||
and `source_list` mean the selected app input (`Netflix`, `TiviMate`, …) and they
|
||||
still do; naming the tier `source` is exactly what made the reading look like
|
||||
media metadata from somewhere else. `media_dag_role` keeps its own separate
|
||||
meaning (that this entity is the "source" role of the media graph).
|
||||
- **`now_playing_state` IS the entity's own state**, so a dashboard never reads two
|
||||
different answers: the state ladder is the authority, and the foreground tier's
|
||||
last-known-playback bridge can still publish a title while the state is already
|
||||
`idle`.
|
||||
|
||||
**Compatibility: `media_dag_now_playing` is GONE** — not deprecated, removed. It
|
||||
was one object; its contents are the attributes above. No alias is kept: a nested
|
||||
copy would double every poll's payload, and these attributes are declared
|
||||
*unrecorded* for exactly that reason (they must not bloat the recorder database). A
|
||||
template that read the object needs one rename:
|
||||
|
||||
```jinja
|
||||
{# before #}
|
||||
{{ state_attr('media_player.shield', 'media_dag_now_playing').title }}
|
||||
{# after #}
|
||||
{{ state_attr('media_player.shield', 'now_playing_title') }}
|
||||
```
|
||||
|
||||
The Jellyfin-backed `sensor.<entry>_now_playing` keeps its own flat set
|
||||
(`playback_state`, `title`, `media_type`, `series`, `season`, `episode`,
|
||||
`duration`, `position`, `thumbnail`), and it follows the same rule: a field the
|
||||
Jellyfin entity did not answer is omitted, so there are no `title: null` rows
|
||||
there either. Its `playback_state` reads `idle` whenever that session is not
|
||||
playing. `sensor.<entry>_current_app` publishes `app_id`/`app_name`, the same
|
||||
identity the media player carries beside its now-playing attributes.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
|
||||
from .adb_session import AdbMediaSessionPoller
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
ADB_COMMAND_SERVICE_DESCRIPTION,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
@@ -28,11 +29,13 @@ from .const import (
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
CONF_REGISTERED_INTENTS,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SERVICE_ADB_COMMAND,
|
||||
SERVICE_SEND_INTENT,
|
||||
)
|
||||
from .helpers import (
|
||||
entry_host,
|
||||
@@ -44,7 +47,16 @@ from .helpers import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||
# t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds
|
||||
# nothing unless the user opted in, so an install that never turns screen capture
|
||||
# on pays nothing for the platform existing (and toggling it is an options reload).
|
||||
PLATFORMS = [
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.SENSOR,
|
||||
Platform.REMOTE,
|
||||
Platform.BUTTON,
|
||||
Platform.IMAGE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
@@ -71,14 +83,59 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
data=block,
|
||||
)
|
||||
)
|
||||
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
|
||||
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
|
||||
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS.
|
||||
# The two APIs the target-bearing registration needs are imported WITH that
|
||||
# registration, next to the handler they belong to (t14): importing the package —
|
||||
# which every config-flow harness does via
|
||||
# ``custom_components.nvidia_shield.config_flow`` — must not drag in the service
|
||||
# registration surface for a domain that harness never sets up.
|
||||
from homeassistant.core import SupportsResponse
|
||||
from homeassistant.helpers.service import async_set_service_schema
|
||||
|
||||
from .media_player import (
|
||||
_ADB_COMMAND_SCHEMA,
|
||||
_INTENT_SCHEMA,
|
||||
_async_adb_command_service,
|
||||
_async_send_intent_service,
|
||||
send_intent_service_description,
|
||||
)
|
||||
|
||||
# t14: the action must be SELECTABLE for the user's Shield. Measured on HA
|
||||
# 2026.10.1: `async_register` has no `target` parameter, so the UI's target
|
||||
# picker comes from the service DESCRIPTION — the runtime
|
||||
# `async_set_service_schema` (which wins over a services.yaml we do not ship)
|
||||
# carries `target: {entity: [{domain: media_player}]}`, so the picker offers the
|
||||
# Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST
|
||||
# because async_set_service_schema reads `hass.services.supports_response()` to
|
||||
# publish the response flag, and the handler returns the command's output under
|
||||
# ADB_COMMAND_RESPONSE_KEY.
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADB_COMMAND,
|
||||
_async_adb_command_service,
|
||||
schema=_ADB_COMMAND_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
)
|
||||
|
||||
# t17: send_intent — the same measured mechanism as t14 (a runtime
|
||||
# description carries the target picker, and the response flag is read from
|
||||
# the registration, so OPTIONAL is registered FIRST). The description is
|
||||
# republished by every async_setup_entry because the intent DROPDOWN is
|
||||
# per-entry data while the service is domain-wide; at domain setup no entry
|
||||
# has loaded yet, so the list is empty and every name is still typeable
|
||||
# (`custom_value`), with the call-time lookup as the authority.
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_INTENT,
|
||||
_async_send_intent_service,
|
||||
schema=_INTENT_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(())
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -102,6 +159,11 @@ def _yaml_seed_blocks(domain_config) -> list[dict]:
|
||||
}
|
||||
if block.get("unique_id"):
|
||||
seed["unique_id"] = str(block["unique_id"])
|
||||
# t17: the intents block travels VERBATIM to the import flow, which owns
|
||||
# the validation (and drops unusable rows with a log line) — the seed
|
||||
# normaliser here only decides which keys reach the flow at all.
|
||||
if isinstance(block.get(CONF_REGISTERED_INTENTS), list):
|
||||
seed[CONF_REGISTERED_INTENTS] = block[CONF_REGISTERED_INTENTS]
|
||||
blocks.append(seed)
|
||||
return blocks
|
||||
|
||||
@@ -178,9 +240,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# t17: refresh the send_intent dropdown from what is ACTUALLY loaded. This
|
||||
# runs on every reload, and an options change reloads the entry, so adding
|
||||
# or removing an intent updates the picker. The union across entries is
|
||||
# deliberate: the service is domain-wide and any of them may be the target.
|
||||
_publish_send_intent_description(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _publish_send_intent_description(hass: HomeAssistant) -> None:
|
||||
"""Republish send_intent's description with the loaded intent names (t17).
|
||||
|
||||
Reads each loaded entry's OPTIONS through the runtime's own view of them
|
||||
(``registered_intents``), so a hand-edited or malformed row cannot reach the
|
||||
picker — the dropdown offers exactly what the action would accept.
|
||||
"""
|
||||
try:
|
||||
from homeassistant.helpers.service import async_set_service_schema
|
||||
|
||||
from .media_player import registered_intents, send_intent_service_description
|
||||
except ImportError: # pragma: no cover - a harness without HA's service module
|
||||
# The dropdown is a RENDERING nicety: the action itself is registered by
|
||||
# async_setup and works without it. Only a stub harness (which loads this
|
||||
# module without the real homeassistant package) can get here.
|
||||
return
|
||||
|
||||
names: list[str] = []
|
||||
for loaded in (hass.data.get(DOMAIN) or {}).values():
|
||||
options = getattr(loaded, "options", None)
|
||||
if options is None:
|
||||
options = getattr(getattr(loaded, "entry", None), "options", None)
|
||||
names.extend(intent["name"] for intent in registered_intents(options or {}))
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(names)
|
||||
)
|
||||
|
||||
|
||||
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import entry_host
|
||||
from .media_player import (
|
||||
intent_default_variables,
|
||||
registered_intents,
|
||||
run_registered_intent,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,8 +30,18 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
entities: list[ButtonEntity] = []
|
||||
if entry.options.get(CONF_ADB_ENABLED, False):
|
||||
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)])
|
||||
entities.append(NvidiaShieldPairAdbButton(hass, entry))
|
||||
# t17: one button per REGISTERED intent. The registration IS the opt-in (the
|
||||
# plan: nothing registered ever runs by itself), so a new intent adds a
|
||||
# button and a removed one takes its button away — the options flow reloads
|
||||
# the entry on every save, which re-runs this setup.
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
for intent in registered_intents(entry.options):
|
||||
entities.append(NvidiaShieldIntentButton(hass, entry, manager, intent))
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
@@ -85,3 +100,75 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
host,
|
||||
)
|
||||
await poller.disconnect()
|
||||
|
||||
|
||||
class NvidiaShieldIntentButton(ButtonEntity):
|
||||
"""One button per REGISTERED intent (t17).
|
||||
|
||||
The whole point for an intent with no variables: pressing it sends the intent
|
||||
with the fields' declared defaults. It deliberately does NOT cache a command
|
||||
— :func:`run_registered_intent` builds it every press, so the button, the
|
||||
action and the tests all exercise one code path.
|
||||
|
||||
Failures are LOUD. A missing required field, ADB being off/down, a transport
|
||||
that never answered and a device that refused the intent all raise with the
|
||||
reason (the device's own message included) — a button that silently did
|
||||
nothing is exactly the "silent success" the plan forbids. An app that
|
||||
rejected the intent is a CONFIGURATION problem, and the raised text says so.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, manager, intent: dict
|
||||
) -> None:
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._manager = manager
|
||||
self._intent = intent
|
||||
self._attr_name = intent["name"]
|
||||
# the intent id is stable across renames, so a rename does not orphan the
|
||||
# entity (and does not create a second one)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_intent_{intent['id']}"
|
||||
self._attr_icon = "mdi:gesture-tap-button"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name=self._entry.title,
|
||||
manufacturer="NVIDIA",
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""What this button will send — the registration, readable (t17).
|
||||
|
||||
A button whose registration the user cannot see is a black box: these are
|
||||
the SAME values the command is built from, and package/action/component
|
||||
are what they typed on the Intents screen.
|
||||
"""
|
||||
return {
|
||||
"intent_id": self._intent["id"],
|
||||
"package": self._intent["package"],
|
||||
"activity": self._intent.get("activity") or None,
|
||||
"action": self._intent.get("action") or None,
|
||||
"categories": list(self._intent.get("categories") or []),
|
||||
"data": self._intent.get("data") or None,
|
||||
"fields": [dict(field) for field in (self._intent.get("fields") or [])],
|
||||
}
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Send the intent with its declared defaults, or raise with the reason."""
|
||||
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
|
||||
variables = intent_default_variables(self._intent)
|
||||
output = await run_registered_intent(
|
||||
self._manager, entity_id, self._intent, variables
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"intent button %s sent %s → %s",
|
||||
entity_id,
|
||||
self._intent["id"],
|
||||
(output or "").strip().splitlines()[:1],
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
||||
# Re-export shared Media DAG constants
|
||||
from .ha_adb_common.const import ( # noqa: F401
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
@@ -38,6 +37,15 @@ CONF_PAIRING_CODE = "pairing_code"
|
||||
# steps — the entry still finishes (passive mode) instead of stranding the
|
||||
# user on a form with no way forward.
|
||||
CONF_SKIP_PAIRING = "skip_pairing"
|
||||
#: t15: a PERSISTED marker that this entry's protocol client really paired (a
|
||||
#: successful ``async_finish_pairing``). Pairing cannot be inferred from anything
|
||||
#: else: the library exposes no ``is_paired``, and certificate material proves only
|
||||
#: that SOMETHING generated a key — which an entry that merely enabled the transport
|
||||
#: without pairing has too. Written by the setup flow's pairing step, the options
|
||||
#: flow's, and the reauth/reconfigure finishers, so enabling the transport later can
|
||||
#: tell "already paired" (reuse the certificate, no new PIN) from "never paired"
|
||||
#: (must pair now, or stay off).
|
||||
CONF_PROTOCOL_PAIRED = "protocol_paired"
|
||||
DEFAULT_PROTOCOL_API_PORT = 6466
|
||||
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
||||
|
||||
@@ -53,6 +61,22 @@ ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
||||
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
||||
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||
|
||||
# t15: the remote-protocol transport's own state machine. The library offers NO
|
||||
# ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the
|
||||
# states we can actually prove — and the one the user was reading as "maybe
|
||||
# unpaired" was ``never_connected``: keep_reconnecting() alone never connects
|
||||
# (its loop is ``while self._remote_message_protocol:``, None until a first
|
||||
# ``async_connect()``), so the transport had never been switched on at all.
|
||||
PROTOCOL_OFF = "off" # no client wired (disabled/no library)
|
||||
PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection
|
||||
PROTOCOL_CONNECTED = "connected" # device reachable at the protocol
|
||||
PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down
|
||||
PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth)
|
||||
|
||||
#: Seconds allowed for the FIRST protocol connect before it counts as unreachable.
|
||||
#: Core androidtv_remote uses the same 5 s before deciding ready/not-ready.
|
||||
PROTOCOL_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
DEFAULT_NAME = "Nvidia Shield"
|
||||
|
||||
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
|
||||
@@ -128,11 +152,164 @@ APP_LABEL_TTL = 24 * 3600
|
||||
# unlikely to collide with a manifest line.
|
||||
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
|
||||
|
||||
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
|
||||
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
|
||||
# procstats + usagestats + batterystats), and the old fetch shipped
|
||||
# ``| head -300`` — which cut before the package sections even appeared, so the
|
||||
# negative was a truncation artefact rather than an answer. Searching on-device
|
||||
# keeps the wire small AND the answer honest: these are the three shapes Android
|
||||
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
|
||||
# name in each).
|
||||
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
|
||||
|
||||
# t66: how many label probes may come back "ran, no label" IN A ROW before the
|
||||
# device is treated as one that exposes no labels at all (this user's Shield).
|
||||
# One label-less package is ordinary (system/OEM packages often have none), so a
|
||||
# single miss proves nothing — but a run of them means the whole firmware keeps
|
||||
# its labels in resources we cannot read, and every further ``pm dump`` per
|
||||
# package is a shell spent on a question already answered. The conclusion expires
|
||||
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
|
||||
# found anywhere resets the run.
|
||||
APP_LABEL_PROBE_LIMIT = 5
|
||||
|
||||
# P4-2a adb_command service: raw ADB shell on this entry — OFF by default
|
||||
# (same security posture as the core androidtv integration).
|
||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||
SERVICE_ADB_COMMAND = "adb_command"
|
||||
|
||||
#: t14: the key the command's output is returned under, in the service RESPONSE.
|
||||
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
|
||||
#: template written against that integration keeps reading the same field.
|
||||
ADB_COMMAND_RESPONSE_KEY = "adb_response"
|
||||
|
||||
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
|
||||
#:
|
||||
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
|
||||
#: parameter, so a target cannot be declared at registration. The frontend renders
|
||||
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
|
||||
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
|
||||
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
|
||||
#: selected as its ``media_player`` entity (one per entry), exactly like the
|
||||
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
|
||||
#:
|
||||
#: The entity domain the action's target declares, and the only domain a
|
||||
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
|
||||
#: image entity — none of those is something to run a raw shell command on).
|
||||
ADB_COMMAND_TARGET_DOMAIN = "media_player"
|
||||
|
||||
ADB_COMMAND_SERVICE_TARGET: dict = {
|
||||
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
|
||||
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
|
||||
#:
|
||||
#: Why a runtime description at all: the description is what the frontend receives
|
||||
#: from ``get_services``, and a service with no description has no ``target``, which
|
||||
#: is precisely why the user's UI offered no Shield to select and he had to run his
|
||||
#: diagnostics through the deprecated integration. The prose here mirrors
|
||||
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
|
||||
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
|
||||
#: asserts the two cannot drift.
|
||||
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
|
||||
"name": "ADB command",
|
||||
"description": (
|
||||
"Send a raw ADB shell command to the persistent connection of an Nvidia "
|
||||
"Shield entry (option-gated use)."
|
||||
),
|
||||
"target": ADB_COMMAND_SERVICE_TARGET,
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
"description": "The shell command to run on the device.",
|
||||
"required": True,
|
||||
"selector": {"text": {}},
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"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."
|
||||
),
|
||||
"selector": {"entity": {"domain": "media_player"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ── t17: registered intents (the second half of the intents feature) ──
|
||||
#
|
||||
# A registered intent is ordinary OPTIONS data: a list of dicts, one per intent,
|
||||
# stored under CONF_REGISTERED_INTENTS in JSON-safe form (no sets, no objects).
|
||||
# It is read by three consumers that must agree about the shape: the options
|
||||
# screen (which writes it), the button platform (one entity per row) and the
|
||||
# nvidia_shield.send_intent action (which builds the command). The shape is the
|
||||
# one docs/INTENTS_PLAN.md §3 fixes:
|
||||
#
|
||||
# {id, name, package, activity, action, categories, data, fields}
|
||||
#
|
||||
# ``activity`` and ``action`` are alternatives (the plan: a component OR an
|
||||
# action, optionally with categories), and ``fields`` declares the variables the
|
||||
# caller may (or must) supply. Only the DATA template carries {placeholders}.
|
||||
CONF_REGISTERED_INTENTS = "registered_intents"
|
||||
|
||||
#: The action that fires a registered intent. Named by the feature, not by the
|
||||
#: mechanism: the device's own vocabulary is "am start", which the user never
|
||||
#: types.
|
||||
SERVICE_SEND_INTENT = "send_intent"
|
||||
|
||||
#: t17: the key the device's `am start` output is returned under. Deliberately
|
||||
#: NOT ``adb_response``: that key belongs to the raw-command action, and a
|
||||
#: template reading it should not silently start seeing intent output.
|
||||
INTENT_RESPONSE_KEY = "intent_response"
|
||||
|
||||
#: The four field types the plan fixes (v1): string/int/bool map to the device's
|
||||
#: own flags (``--es``/``--ei``/``--ez``), and ``raw`` passes an extra through
|
||||
#: verbatim — the documented escape hatch, NOT the default (the plan's §9
|
||||
#: non-goal is that nothing beyond these four is expressible).
|
||||
INTENT_FIELD_TYPES = ("string", "int", "bool", "raw")
|
||||
|
||||
#: The flag each declared type becomes on ``am start``.
|
||||
INTENT_TYPE_FLAGS = {
|
||||
"string": "--es",
|
||||
"int": "--ei",
|
||||
"bool": "--ez",
|
||||
"raw": "--es",
|
||||
}
|
||||
|
||||
#: The explicit "Add new" sentinel of the intents selector. A registered intent
|
||||
#: id can never collide with it: ids must be slug-shaped (letters, digits,
|
||||
#: underscore), and this one is not.
|
||||
INTENT_NEW_SENTINEL = "__new__"
|
||||
|
||||
#: The options-flow fields of the per-intent form (one row at a time, the same
|
||||
#: idiom as the apps editor). The declared fields are edited as TEXT, one
|
||||
#: ``key:type[:required][=default]`` per line, because that is the shape the user
|
||||
#: already knows from the apps table and it survives a paste.
|
||||
INTENT_FIELD_CHOICE = "intent_choice"
|
||||
INTENT_FIELD_NAME = "intent_name"
|
||||
INTENT_FIELD_PACKAGE = "intent_package"
|
||||
INTENT_FIELD_ACTIVITY = "intent_activity"
|
||||
INTENT_FIELD_ACTION = "intent_action"
|
||||
INTENT_FIELD_CATEGORIES = "intent_categories"
|
||||
INTENT_FIELD_DATA = "intent_data"
|
||||
INTENT_FIELD_DECLARED = "intent_fields"
|
||||
INTENT_FIELD_CANDIDATE = "intent_candidate"
|
||||
INTENT_FIELD_SCAN_PACKAGE = "intent_scan_package"
|
||||
|
||||
#: How many declared fields one intent may carry, and how many intents an entry
|
||||
#: may register. Bounded so a hand-edited options blob (or a runaway paste)
|
||||
#: cannot turn one button press into a command with thousands of flags, and so
|
||||
#: the options page stays readable.
|
||||
INTENT_FIELD_LIMIT = 20
|
||||
INTENT_REGISTRATION_LIMIT = 50
|
||||
|
||||
#: The same target picker as adb_command (t14's measured mechanism), reused
|
||||
#: verbatim: an intent is sent to exactly ONE Shield, selected as its media_player
|
||||
#: entity, and the runtime description is what renders the picker.
|
||||
INTENT_SERVICE_TARGET: dict = {
|
||||
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
|
||||
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
||||
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
||||
# reports stability on their device.
|
||||
@@ -142,13 +319,38 @@ CONF_LOGCAT_EVENTS = "logcat_events"
|
||||
# re-stamped (repeat suppression).
|
||||
LOGCAT_SUPPRESS_WINDOW = 15.0
|
||||
|
||||
# P4-2c BrowseMedia app directory fallback when the options map is empty
|
||||
# (curated; the packages are the plan's reference apps).
|
||||
# P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
|
||||
# directory fallback and the third tier of the app-name chain (after the user's
|
||||
# own map and a device label, before the raw package id) — on a Shield whose
|
||||
# `pm dump` carries no label at all it is the answer that makes names work.
|
||||
#
|
||||
# It is a community-maintained convenience, NOT device truth: package ids are
|
||||
# stable, display names change with vendors and app updates, and an app this
|
||||
# table has never heard of falls back to its package id (shown once). Users
|
||||
# override any entry in Configure > Apps, and that edit always wins. Adding a row
|
||||
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
|
||||
DEFAULT_APP_DIRECTORY = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
|
||||
"com.amazon.amazonvideo.livingroom": "Prime Video",
|
||||
"com.esaba.downloader": "Downloader",
|
||||
"com.github.damontecres.wholphin": "Wholphin",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
|
||||
"com.google.android.speech.pumpkin": "Google Speech Services",
|
||||
"com.google.android.tts": "Speech Services by Google",
|
||||
"com.google.android.tvlauncher": "Google TV launcher",
|
||||
"com.google.android.youtube.tv": "YouTube",
|
||||
"com.hyperion.grabber": "Hyperion Grabber",
|
||||
"com.lonelycatgames.Xplore": "X-plore File Manager",
|
||||
"com.nendo.argosy": "Argosy",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.plexapp.mediaserver.smb": "Plex Media Server",
|
||||
"com.retroarch": "RetroArch",
|
||||
"com.spocky.projengmenu": "Projectivy Launcher",
|
||||
"dev.jdtech.jellyfin": "Findroid",
|
||||
"dev.trooped.tvquickbars": "TV Quick Actions",
|
||||
"flar2.homebutton": "Button Mapper",
|
||||
"tv.pluto.android": "Pluto TV",
|
||||
}
|
||||
|
||||
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
|
||||
@@ -263,6 +465,11 @@ SCRIPT_VAR_NUM_REPEATS = "num_repeats"
|
||||
SCRIPT_VAR_DELAY_SECS = "delay_secs"
|
||||
SCRIPT_VAR_HOLD_SECS = "hold_secs"
|
||||
|
||||
# t66: the screen-capture image entity — OFF by default. A capture is a
|
||||
# full-resolution screen grab, and the user's standing constraint is that screen
|
||||
# capture must never drive state; opt-in keeps a diagnostic surface from costing
|
||||
# every install a grab per dashboard view.
|
||||
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
|
||||
|
||||
def remote_command_script(options) -> str | None:
|
||||
"""The script configured for remote.send_command, or None (t47).
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||
|
||||
from .adb_client import AdbClientBase, parse_wakefulness
|
||||
from .app_label import parse_application_label
|
||||
from .app_label import (
|
||||
parse_app_label,
|
||||
parse_application_label,
|
||||
parse_label_res,
|
||||
parse_non_localized_label,
|
||||
resolve_package_name,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
@@ -19,6 +24,30 @@ from .installed_apps import (
|
||||
parse_package_list,
|
||||
)
|
||||
from .intent import PlaybackIntent
|
||||
from .intents import (
|
||||
INTENT_CANDIDATE_LIMIT,
|
||||
INTENT_ENTRY_CANDIDATES,
|
||||
INTENT_ENTRY_EMPTY,
|
||||
INTENT_SCAN_ADB_DISABLED,
|
||||
INTENT_SCAN_ADB_UNAVAILABLE,
|
||||
INTENT_SCAN_CACHED,
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
INTENT_SCAN_INVALID_PACKAGE,
|
||||
INTENT_SCAN_TTL,
|
||||
INTENT_SCAN_UNREADABLE,
|
||||
INTERNAL_MARKERS,
|
||||
IntentCandidate,
|
||||
IntentScanCache,
|
||||
IntentScanResult,
|
||||
activity_resolver_section,
|
||||
has_activity_resolver_table,
|
||||
intent_scan_command,
|
||||
intent_scan_fallback_command,
|
||||
is_likely_internal,
|
||||
parse_activity_resolver_table,
|
||||
parse_intent_report,
|
||||
)
|
||||
from .keys import ensure_adb_key
|
||||
from .logcat_events import foreground_from_logcat
|
||||
from .media_session import (
|
||||
@@ -31,17 +60,42 @@ from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||
|
||||
__all__ = [
|
||||
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
|
||||
"ATTR_MEDIA_DAG_NOW_PLAYING",
|
||||
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
|
||||
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
|
||||
"ATTR_MEDIA_DAG_ROLE",
|
||||
"ATTR_MEDIA_DAG_RUNNING_APPS",
|
||||
"AdbClientBase",
|
||||
"INTENT_CANDIDATE_LIMIT",
|
||||
"INTENT_ENTRY_CANDIDATES",
|
||||
"INTENT_ENTRY_EMPTY",
|
||||
"INTENT_SCAN_ADB_DISABLED",
|
||||
"INTENT_SCAN_ADB_UNAVAILABLE",
|
||||
"INTENT_SCAN_CACHED",
|
||||
"INTENT_SCAN_FRESH",
|
||||
"INTENT_SCAN_FRESH_EMPTY",
|
||||
"INTENT_SCAN_INVALID_PACKAGE",
|
||||
"INTENT_SCAN_TTL",
|
||||
"INTENT_SCAN_UNREADABLE",
|
||||
"INTERNAL_MARKERS",
|
||||
"IntentCandidate",
|
||||
"IntentScanCache",
|
||||
"IntentScanResult",
|
||||
"MediaSessionInfo",
|
||||
"PlaybackIntent",
|
||||
"activity_resolver_section",
|
||||
"has_activity_resolver_table",
|
||||
"intent_scan_command",
|
||||
"intent_scan_fallback_command",
|
||||
"is_likely_internal",
|
||||
"parse_activity_resolver_table",
|
||||
"parse_intent_report",
|
||||
"ensure_adb_key",
|
||||
"parse_all_media_sessions",
|
||||
"parse_app_label",
|
||||
"parse_application_label",
|
||||
"parse_label_res",
|
||||
"parse_non_localized_label",
|
||||
"resolve_package_name",
|
||||
"parse_audio_playback",
|
||||
"parse_foreground",
|
||||
"foreground_from_logcat",
|
||||
|
||||
@@ -1,19 +1,55 @@
|
||||
"""App friendly-name resolution from the package manifest (P5-1).
|
||||
"""App friendly-name resolution from the package dump (P5-1, t66).
|
||||
|
||||
Parses `pm dump <package>` output for the ``application-label`` line — the
|
||||
default-locale variant; localized suffixed variants
|
||||
(``application-label-<lang>:``) are skipped, and multi-label blocks across
|
||||
locales are tolerated (first default-locale label wins), mirroring the
|
||||
tolerant pattern of the androidtv-adb core helper.
|
||||
Parses the label candidate LINES of a `pm dump <package>` / `dumpsys package`
|
||||
answer. Three shapes are searched, in priority order, because Android firmware
|
||||
prints different ones (and one of them on this user's Shield prints nothing at
|
||||
all):
|
||||
|
||||
1. ``application-label: 'Netflix'`` — the AAPT-style manifest label, with its
|
||||
localized suffixed variants (``application-label-<lang>:``) as a fallback.
|
||||
2. ``nonLocalizedLabel=Netflix`` — the ApplicationInfo field. It is usually
|
||||
``null`` on a TV device (the label comes from a resource), so a null/empty
|
||||
value is not a name.
|
||||
3. ``labelRes=0x7f0f0001`` — a RESOURCE ID, not text. A dump can only name the
|
||||
app here if it prints a quoted string, so a numeric/hex id is deliberately
|
||||
NOT used as a name: we would be showing ``0x7f0f0001`` as if it were a name.
|
||||
|
||||
Nothing here is a guess about which line exists: every branch is anchored on the
|
||||
literal prefix Android prints, and a miss returns None so the caller falls
|
||||
through to the curated map (the answer on a device with no labels at all).
|
||||
|
||||
Also exports the ONE package -> name precedence chain, so the media player, the
|
||||
Current App sensor, the discovery table and the options editor cannot drift:
|
||||
user map > device label > curated map > raw package id.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
#: ``application-label: 'X'`` / ``application-label-de: "X"`` — the AAPT form.
|
||||
_APPLICATION_LABEL_RE = re.compile(
|
||||
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||
)
|
||||
|
||||
#: ``nonLocalizedLabel=X`` — bare word, quoted string, or ``null``.
|
||||
_NON_LOCALIZED_LABEL_RE = re.compile(
|
||||
r"nonLocalizedLabel=(?:'([^']*)'|\"([^\"]*)\"|([^\s,}]+))"
|
||||
)
|
||||
|
||||
#: ``labelRes=0x7f0f0001`` — only a QUOTED value is text; an id is not a name.
|
||||
_LABEL_RES_RE = re.compile(r"labelRes=(?:'([^']*)'|\"([^\"]*)\")")
|
||||
|
||||
#: Values a dump prints that are not names.
|
||||
_NOT_A_NAME = ("", "null", "0x0", "0")
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str | None:
|
||||
"""A candidate label, or None when it is empty/``null``."""
|
||||
text = (value or "").strip()
|
||||
if text.lower() in _NOT_A_NAME:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def parse_application_label(dump: str) -> str | None:
|
||||
"""The package's default-locale application label, or None.
|
||||
@@ -24,7 +60,92 @@ def parse_application_label(dump: str) -> str | None:
|
||||
if not dump:
|
||||
return None
|
||||
for match in _APPLICATION_LABEL_RE.finditer(dump):
|
||||
label = (match.group(1) or match.group(2) or "").strip()
|
||||
if label and label not in ("null", ""):
|
||||
label = _clean(match.group(1) or match.group(2))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_non_localized_label(dump: str) -> str | None:
|
||||
"""The ``nonLocalizedLabel=`` value, or None (t66).
|
||||
|
||||
``null`` is the common case on a TV and is NOT a name.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _NON_LOCALIZED_LABEL_RE.finditer(dump):
|
||||
label = _clean(match.group(1) or match.group(2) or match.group(3))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_label_res(dump: str) -> str | None:
|
||||
"""A QUOTED ``labelRes=`` value, or None (t66).
|
||||
|
||||
``labelRes=0x7f0f0001`` is a resource id — showing it as an app name would be
|
||||
worse than showing the package id, so only a quoted string counts.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _LABEL_RES_RE.finditer(dump):
|
||||
label = _clean(match.group(1) or match.group(2))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_app_label(dump: str) -> str | None:
|
||||
"""The best device label in ``dump``, trying every shape Android prints.
|
||||
|
||||
Priority follows how specific the shape is: the AAPT manifest label first,
|
||||
then the ApplicationInfo field, then a quoted resource label. None means the
|
||||
device did not name this app — which, on a device whose dump carries no
|
||||
labels at all, is the definitive answer that sends the caller to the curated
|
||||
map (t66).
|
||||
"""
|
||||
return (
|
||||
parse_application_label(dump)
|
||||
or parse_non_localized_label(dump)
|
||||
or parse_label_res(dump)
|
||||
)
|
||||
|
||||
|
||||
def resolve_package_name(
|
||||
package: str | None,
|
||||
*,
|
||||
user_map=None,
|
||||
device_label=None,
|
||||
curated=None,
|
||||
) -> str | None:
|
||||
"""THE package -> display-name chain (t66): one order, every surface.
|
||||
|
||||
1. the user's explicit apps map (their own edit always wins);
|
||||
2. the device's own label, when the device exposes one;
|
||||
3. the curated community map (the answer on a device with no labels: see
|
||||
``DEFAULT_APP_DIRECTORY``);
|
||||
4. the raw package id, shown ONCE.
|
||||
|
||||
``device_label`` may be a VALUE or a CALLABLE. A callable is looked up only
|
||||
after the user map has missed, which is what keeps tier 2 LAZY: the manager's
|
||||
``app_label()`` accessor enqueues an on-demand device fetch on a miss, so
|
||||
calling it for an app the user has already named would spend a shell to learn
|
||||
something nobody reads.
|
||||
|
||||
Returns None only for an empty package, so a caller can always display
|
||||
something. The id is never doubled into a ``pkg=pkg`` row: tier 4 IS the id,
|
||||
and there is no tier that formats it as a name as well.
|
||||
"""
|
||||
if not package:
|
||||
return None
|
||||
mapped = (user_map or {}).get(package)
|
||||
if mapped:
|
||||
return str(mapped)
|
||||
raw_label = device_label(package) if callable(device_label) else device_label
|
||||
label = _clean(raw_label)
|
||||
if label:
|
||||
return label
|
||||
curated_name = (curated or {}).get(package)
|
||||
if curated_name:
|
||||
return str(curated_name)
|
||||
return package
|
||||
@@ -17,9 +17,11 @@ ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
|
||||
# Jellyfin media_player entity for rich metadata
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
|
||||
|
||||
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
|
||||
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
|
||||
# t65 flattened the now-playing reading into separate top-level ``now_playing_*``
|
||||
# attributes on the media player (``now_playing_via`` names the tier), so the single
|
||||
# nested ``media_dag_now_playing`` key this file used to define is GONE — the last
|
||||
# reference to it was removed in t66's cleanup. The flattened names live in
|
||||
# ``custom_components/nvidia_shield/media_player.py`` (``now_playing_attribute_names``).
|
||||
|
||||
# List of running app dicts: [{package, state, title?, artist?}, ...]
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Intent discovery from an app's Activity Resolver Table (t16).
|
||||
|
||||
The approved intents feature (``docs/INTENTS_PLAN.md`` §2) starts here: the user
|
||||
scans ONE installed app and we answer with the intents that app advertises. The
|
||||
source of truth is the ``Activity Resolver Table:`` section Android prints in
|
||||
``pm dump <package>``.
|
||||
|
||||
WHY THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield (captured
|
||||
2026-10-12) ``pm dump com.netflix.ninja`` is ~2241 lines of WHOLE dumpsys —
|
||||
``DUMP OF SERVICE package:`` and then activity/meminfo/procstats/usagestats/
|
||||
batterystats — for ONE app. Shipping that over ADB to read a few dozen resolver
|
||||
lines is the same mistake ``| head -300`` made for app labels (t66, where the
|
||||
truncation was misread as "this app has no label"), so the command built here
|
||||
pipes the dump through ``sed`` ON THE DEVICE and only the resolver section
|
||||
crosses the wire:
|
||||
|
||||
pm dump <pkg> 2>/dev/null \
|
||||
| sed -n "/Activity Resolver Table/,/Service Resolver Table/p" \
|
||||
| head -n <cap>
|
||||
|
||||
with a bounded ``grep -A … -m 1 -F`` fallback (``intent_scan_fallback_command``)
|
||||
for a device whose toolbox cannot do sed ranges. The package id is always
|
||||
``shlex.quote``d — a package name is the only thing in either command that could
|
||||
carry shell metacharacters, and quoting it is what makes the command safe — and
|
||||
everything else in the command is a fixed constant from this file.
|
||||
|
||||
WHAT THE CANDIDATES CARRY AND WHY. Per §3's data model: component, action,
|
||||
categories, and the data (scheme, authority, path). A filter that carries several
|
||||
schemes, authorities or paths is expanded into ONE CANDIDATE PER COMBINATION,
|
||||
because each of those is a concrete match — a URI the app really accepts — and a
|
||||
picker row that mixes two schemes cannot be turned into a URI at all. Candidates
|
||||
are deduplicated on the whole tuple, so the expansion costs nothing when the
|
||||
app's filters coincide.
|
||||
|
||||
WHAT THIS MODULE DOES NOT MODEL: MIME types. The resolver key of a MIME filter
|
||||
(``video/*:``) is not one of §3's fields, and inventing a data URI out of it
|
||||
would be worse than reporting the filter through its component, action and
|
||||
categories — which is what happens. Nothing is dropped for being unmodelled.
|
||||
|
||||
MARK — READ THIS BEFORE TRUSTING A SHAPE. The parser is proven against a
|
||||
TRANSCRIBED capture (``tests/fixtures/pm_dump_netflix_resolver.txt``), not
|
||||
against a device: no real Shield was available, and the raw capture text is not
|
||||
in this repository either. The entry/detail shapes below are what the capture
|
||||
records (see the fixture's own notes for which lines are verbatim and which are
|
||||
reconstructed), and every parser branch is deliberately tolerant of a shape we
|
||||
have never seen: an unrecognised line degrades to "no candidate from this line",
|
||||
never to a wrong candidate.
|
||||
|
||||
NOT TO BE CONFUSED WITH ``.intent``: that module holds ``PlaybackIntent``, the
|
||||
optimistic-UI dataclass, not an Android intent. §7.4 of the plan suggests renaming
|
||||
it to ``playback_intent.py``; that churn is not part of this unit, so the two names
|
||||
sit side by side here and this note is the signpost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: ``Activity Resolver Table:`` — the section that opens the package's resolver
|
||||
#: tables, and the two headings that END it on the observed capture (the receiver
|
||||
#: table follows the activity one, then the service table).
|
||||
ACTIVITY_TABLE_HEADER = "Activity Resolver Table:"
|
||||
RECEIVER_TABLE_HEADER = "Receiver Resolver Table:"
|
||||
SERVICE_TABLE_HEADER = "Service Resolver Table:"
|
||||
PROVIDER_TABLE_HEADER = "Provider Resolver Table:"
|
||||
|
||||
#: The end of the activity section: whichever of these comes FIRST. Stopping at
|
||||
#: the first one can only ever cut the section short, never extend it into
|
||||
#: another table (the plan's sed uses the service heading; a capture where the
|
||||
#: receiver table is absent still ends here).
|
||||
_SECTION_ENDS = (
|
||||
RECEIVER_TABLE_HEADER,
|
||||
SERVICE_TABLE_HEADER,
|
||||
PROVIDER_TABLE_HEADER,
|
||||
)
|
||||
|
||||
#: The two patterns the ON-DEVICE sed range is built from. The colons are dropped
|
||||
#: deliberately: the shipped command is the plan's own line
|
||||
#: (``sed -n '/Activity Resolver Table/,/Service Resolver Table/p'``), which is
|
||||
#: what has to be recognisable in a shell and in a review, and both patterns match
|
||||
#: the same printed heading. They are constants — never caller text — so quoting
|
||||
#: them in the command below cannot be broken from outside.
|
||||
SED_RANGE_START = "Activity Resolver Table"
|
||||
SED_RANGE_END = "Service Resolver Table"
|
||||
|
||||
#: How long a scan that RAN is reused, per package. An app's advertised intents
|
||||
#: only change when the app does, so this is a cost bound rather than a staleness
|
||||
#: guess: one shell an hour for a package the user keeps looking at.
|
||||
INTENT_SCAN_TTL = 3600.0
|
||||
|
||||
#: The transfer bound of both commands (``head -n`` / ``grep -A``). A real
|
||||
#: resolver section is tens of lines; this is what keeps a pathological table
|
||||
#: from becoming a wall of text on the wire.
|
||||
INTENT_SCAN_LINE_LIMIT = 400
|
||||
|
||||
#: The cap on returned candidates, applied AFTER dedup and ranking. The cap is
|
||||
#: disclosed (``IntentParseResult.truncated`` / ``IntentScanResult.truncated``),
|
||||
#: so a capped answer never looks complete.
|
||||
INTENT_CANDIDATE_LIMIT = 200
|
||||
|
||||
#: One entry line: an optional group key, the component's hex id, the COMPONENT
|
||||
#: and — for an activity filter — the filter's own id. Both shapes the capture
|
||||
#: records match, with or without the group key in front:
|
||||
#:
|
||||
#: cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
#: deca7fa com.netflix.ninja/.MainActivity filter fc483a2
|
||||
_ENTRY_RE = re.compile(
|
||||
r"^(?P<key>.*?)\s*(?P<hex_id>[0-9a-fA-F]{6,})\s+"
|
||||
r"(?P<component>[A-Za-z_][\w.]*/[\w.$]+)"
|
||||
r"(?:\s+filter\s+(?P<filter_id>[0-9a-fA-F]+))?\s*$"
|
||||
)
|
||||
|
||||
#: A group heading on its own line (``Schemes:``, ``Non-Data Actions:``, and the
|
||||
#: action-keyed groups inside it). Used for ONE fallback only: when a filter
|
||||
#: carries no ``Action:`` detail, the key that grouped it is the action the
|
||||
#: device printed — never a guess about a key that is not action-shaped.
|
||||
_GROUP_KEY_RE = re.compile(r"^\s+(?P<key>[A-Za-z_][\w.$]*)\s*:\s*$")
|
||||
|
||||
#: A filter detail line. The five fields §3's model carries; ``Type:`` (a MIME
|
||||
#: type) and anything else are read off the line and ignored — see the module
|
||||
#: docstring on what is not modelled.
|
||||
_DETAIL_RE = re.compile(
|
||||
r"^\s*(?P<field>Action|Category|Scheme|Authority|Path)\s*:\s*(?P<value>.+?)\s*$"
|
||||
)
|
||||
|
||||
#: ``Authority: "www.netflix.com": -1`` — the authority, not its weight.
|
||||
_AUTHORITY_RE = re.compile(r'^"(?P<value>[^"]*)"\s*(?::\s*-?\d+)?$')
|
||||
|
||||
#: ``Path: "PatternMatcher{GLOB: /title.*}"`` — the matcher kind and its value.
|
||||
#: Both separators Android prints are accepted (``GLOB: /x`` and ``LITERAL, /x``).
|
||||
_PATH_MATCHER_RE = re.compile(
|
||||
r"^PatternMatcher\{(?P<kind>[A-Za-z_]+)\s*[:,]?\s*(?P<value>.*)\}$"
|
||||
)
|
||||
|
||||
#: The marker families ``docs/INTENTS_PLAN.md`` §2 names as internal-looking.
|
||||
#: They are FLAGGED and never deleted: the user decides what is worth
|
||||
#: registering, and a candidate we quietly removed is one they can never
|
||||
#: register. Matching is on the action and the component (the capture's own
|
||||
#: noise is action-shaped, e.g. ``com.netflix.action.DEBUG_PUSH_NOTIFICATION``).
|
||||
INTERNAL_MARKERS = (
|
||||
"DEBUG_",
|
||||
"androidx.",
|
||||
"com.google.firebase.",
|
||||
"ConstraintProxy",
|
||||
)
|
||||
|
||||
#: A cache entry's state (t16). ONLY a scan that RAN writes an entry, and the
|
||||
#: entry then says which of these two answers it holds — the distinction the
|
||||
#: discovery-cache defect taught us, made structural instead of remembered.
|
||||
INTENT_ENTRY_CANDIDATES = "candidates"
|
||||
INTENT_ENTRY_EMPTY = "empty"
|
||||
|
||||
#: A scan result's status. ``answered`` is True for the first three only: the
|
||||
#: rest mean "we do not know", which is NOT the same as "this app advertises
|
||||
#: nothing" and must never be shown as an empty list of intents.
|
||||
INTENT_SCAN_FRESH = "fresh"
|
||||
INTENT_SCAN_FRESH_EMPTY = "fresh_empty"
|
||||
INTENT_SCAN_CACHED = "cached"
|
||||
INTENT_SCAN_ADB_DISABLED = "adb_disabled"
|
||||
INTENT_SCAN_ADB_UNAVAILABLE = "adb_unavailable"
|
||||
INTENT_SCAN_UNREADABLE = "unreadable"
|
||||
INTENT_SCAN_INVALID_PACKAGE = "invalid_package"
|
||||
|
||||
#: The statuses whose candidates are an ANSWER from the device.
|
||||
_ANSWERED_STATUSES = (
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
INTENT_SCAN_CACHED,
|
||||
)
|
||||
|
||||
|
||||
def _unquote(value: str | None) -> str:
|
||||
"""Strip one layer of matching quotes; DROP an unterminated one (t21/F2).
|
||||
|
||||
``"x"`` -> ``x``. A value that OPENS a quote and never closes it is not a
|
||||
value we know: the capture was cut mid-detail (``Scheme: "ht``,
|
||||
``Authority: "www.exam``, ``Path: "PatternMatcher{GLOB: /title``), and
|
||||
believing the leftover produced a scheme, host or pattern that LOOKS real
|
||||
while no app can match it — `_candidate_data` would then prefill the form
|
||||
with a URL that cannot work. An unterminated value is therefore dropped
|
||||
(empty here; the callers turn that into None), so a corrupt detail is never
|
||||
mistaken for a real one. It was never an injection either way (every value is
|
||||
quoted when it is used), which is exactly why the failure was invisible.
|
||||
"""
|
||||
text = (value or "").strip()
|
||||
if text[:1] in ("'", '"'):
|
||||
if len(text) >= 2 and text[-1] == text[0]:
|
||||
return text[1:-1].strip()
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _authority_value(raw: str) -> str | None:
|
||||
"""The authority in an ``Authority:`` detail, or None.
|
||||
|
||||
Android prints ``Authority: "www.netflix.com": -1`` (quoted, then the filter's
|
||||
score); a bare ``Authority: www.netflix.com`` is accepted too.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
match = _AUTHORITY_RE.match(text)
|
||||
if match:
|
||||
return match.group("value") or None
|
||||
return _unquote(text) or None
|
||||
|
||||
|
||||
def _path_value(raw: str) -> tuple[str | None, str | None]:
|
||||
"""``(matcher_kind, path)`` from a ``Path:`` detail.
|
||||
|
||||
``"PatternMatcher{GLOB: /title.*}"`` -> ``("GLOB", "/title.*")``;
|
||||
``"PatternMatcher{LITERAL, /browse}"`` -> ``("LITERAL", "/browse")``; anything
|
||||
that is not a PatternMatcher is returned as a bare path with no kind.
|
||||
"""
|
||||
text = _unquote(raw)
|
||||
match = _PATH_MATCHER_RE.match(text)
|
||||
if match:
|
||||
return match.group("kind").upper(), match.group("value").strip()
|
||||
return None, text or None
|
||||
|
||||
|
||||
def looks_like_action(key: str | None) -> bool:
|
||||
"""Is a group key an ACTION rather than a sub-heading (t16)?
|
||||
|
||||
``android.intent.action.MAIN`` and ``com.netflix.action.DIAL_START`` are
|
||||
action-shaped; ``Schemes``, ``Non-Data Actions`` and the ``cast:``/``http:``
|
||||
scheme keys are not. Only used when a filter printed no ``Action:`` detail.
|
||||
"""
|
||||
text = (key or "").strip().rstrip(":")
|
||||
return bool(text) and "." in text and " " not in text
|
||||
|
||||
|
||||
def is_likely_internal(action: str | None, component: str | None = None) -> bool:
|
||||
"""Does this candidate LOOK like the app's own internal plumbing (t16)?
|
||||
|
||||
A heuristic, and labelled as one: the four families the plan names are
|
||||
matched anywhere in the action or the component, and the flag only moves a
|
||||
candidate to the END of the ordering (``rank_candidates``) — it never removes
|
||||
it, because the user is the one who decides what is worth registering.
|
||||
"""
|
||||
action_text = action or ""
|
||||
component_text = component or ""
|
||||
return any(
|
||||
marker in action_text or marker in component_text
|
||||
for marker in INTERNAL_MARKERS
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentCandidate:
|
||||
"""One intent an app advertises, as its resolver table describes it (t16).
|
||||
|
||||
``component`` is the ``package/Activity`` the filter belongs to; ``action``
|
||||
is the intent action (never invented: see ``looks_like_action``); the data
|
||||
fields are one concrete match, so ``scheme``/``authority``/``path`` together
|
||||
describe a URI the app accepts. ``path_kind`` is Android's matcher
|
||||
(``GLOB``/``LITERAL``/``PREFIX``) and is kept because ``/title.*`` is a glob,
|
||||
not a path: a caller that treats it as literal builds the wrong URI.
|
||||
"""
|
||||
|
||||
component: str
|
||||
action: str | None = None
|
||||
categories: tuple[str, ...] = ()
|
||||
scheme: str | None = None
|
||||
authority: str | None = None
|
||||
path: str | None = None
|
||||
path_kind: str | None = None
|
||||
likely_internal: bool = False
|
||||
|
||||
@property
|
||||
def key(self) -> tuple:
|
||||
"""The dedup key: everything the candidate means, nothing about its row."""
|
||||
return (
|
||||
self.component,
|
||||
self.action,
|
||||
self.categories,
|
||||
self.scheme,
|
||||
self.authority,
|
||||
self.path,
|
||||
self.path_kind,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""A plain dict, for the options page, diagnostics and JSON."""
|
||||
return {
|
||||
"component": self.component,
|
||||
"action": self.action,
|
||||
"categories": list(self.categories),
|
||||
"scheme": self.scheme,
|
||||
"authority": self.authority,
|
||||
"path": self.path,
|
||||
"path_kind": self.path_kind,
|
||||
"likely_internal": self.likely_internal,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentParseResult:
|
||||
"""What one capture yielded: the candidates, and whether the cap applied."""
|
||||
|
||||
candidates: tuple[IntentCandidate, ...] = ()
|
||||
found: int = 0
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
def has_activity_resolver_table(output: str | None) -> bool:
|
||||
"""Does this capture carry the resolver section at all (t16)?
|
||||
|
||||
THE distinction this module exists to keep: a capture WITH the heading that
|
||||
holds no entries is an ANSWER ("this app advertises nothing we can read"),
|
||||
while a capture WITHOUT it is not an answer at all — the transport handed us
|
||||
something else (an error line, a truncated answer, nothing). Only the former
|
||||
may be cached (``IntentScanCache``); the latter must be retried.
|
||||
"""
|
||||
return bool(output) and ACTIVITY_TABLE_HEADER in output
|
||||
|
||||
|
||||
def activity_resolver_section(output: str | None) -> str | None:
|
||||
"""The ``Activity Resolver Table:`` section of a capture, or None.
|
||||
|
||||
Bounded by the FIRST of the receiver/service/provider headings, so a capture
|
||||
that carries the whole dumpsys cannot leak another table's filters (a
|
||||
receiver's actions are not an activity's intents). None when the section is
|
||||
absent — see ``has_activity_resolver_table`` for why that is not "no intents".
|
||||
"""
|
||||
if not output:
|
||||
return None
|
||||
start = output.find(ACTIVITY_TABLE_HEADER)
|
||||
if start < 0:
|
||||
return None
|
||||
body = output[start + len(ACTIVITY_TABLE_HEADER):]
|
||||
end = len(body)
|
||||
for heading in _SECTION_ENDS:
|
||||
found = body.find(heading)
|
||||
if 0 <= found < end:
|
||||
end = found
|
||||
return body[:end]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
"""A filter line being read: its component, its key, its own filter id."""
|
||||
|
||||
component: str
|
||||
key: str | None = None
|
||||
filter_id: str | None = None
|
||||
|
||||
@property
|
||||
def group_action(self) -> str | None:
|
||||
"""The group key as an action, when it is action-shaped (else None)."""
|
||||
key = self.key
|
||||
return key.rstrip(":") if looks_like_action(key) else None
|
||||
|
||||
|
||||
def _iter_filters(section: str) -> Iterator[tuple[_Entry, list[tuple[str, str]]]]:
|
||||
"""Yield ``(entry, details)`` for every filter line in the section.
|
||||
|
||||
Details belong to the filter line they follow, so the walk is line-order and
|
||||
resets on each new entry. A group key on its own line is remembered for the
|
||||
action fallback (``_Entry.group_action``).
|
||||
"""
|
||||
entry: _Entry | None = None
|
||||
details: list[tuple[str, str]] = []
|
||||
group_key: str | None = None
|
||||
for line in section.splitlines():
|
||||
match = _ENTRY_RE.match(line)
|
||||
if match:
|
||||
if entry is not None:
|
||||
yield entry, details
|
||||
key = (match.group("key") or "").strip() or group_key
|
||||
entry = _Entry(
|
||||
component=match.group("component"),
|
||||
key=key,
|
||||
filter_id=match.group("filter_id"),
|
||||
)
|
||||
details = []
|
||||
continue
|
||||
detail = _DETAIL_RE.match(line)
|
||||
if detail is not None:
|
||||
if entry is not None:
|
||||
details.append((detail.group("field"), detail.group("value")))
|
||||
continue
|
||||
heading = _GROUP_KEY_RE.match(line)
|
||||
if heading is not None:
|
||||
group_key = heading.group("key")
|
||||
if entry is not None:
|
||||
yield entry, details
|
||||
|
||||
|
||||
def _data_specs(details: list[tuple[str, str]]) -> list[tuple]:
|
||||
"""The ``(scheme, authority, path, path_kind)`` combinations of one filter.
|
||||
|
||||
``Scheme:`` opens a group; the ``Authority:``/``Path:`` lines that follow it
|
||||
belong to it (that is the nesting the capture prints). A filter with none of
|
||||
the three yields a single all-None spec: "this filter matches on its action
|
||||
alone", which is exactly what a Non-Data Action is.
|
||||
"""
|
||||
groups: list[list] = []
|
||||
for field, raw in details:
|
||||
if field == "Scheme":
|
||||
groups.append([_unquote(raw) or None, [], []])
|
||||
continue
|
||||
if field not in ("Authority", "Path"):
|
||||
continue
|
||||
if not groups:
|
||||
groups.append([None, [], []])
|
||||
target = groups[-1]
|
||||
if field == "Authority":
|
||||
value = _authority_value(raw)
|
||||
if value:
|
||||
target[1].append(value)
|
||||
else:
|
||||
target[2].append(_path_value(raw))
|
||||
|
||||
specs: list[tuple] = []
|
||||
for scheme, authorities, paths in groups:
|
||||
for authority in authorities or [None]:
|
||||
for path_kind, path in paths or [(None, None)]:
|
||||
specs.append((scheme, authority, path, path_kind))
|
||||
return specs
|
||||
|
||||
|
||||
def _candidates_for(entry: _Entry, details: list[tuple[str, str]]) -> list[IntentCandidate]:
|
||||
"""Every candidate one filter line yields (action x data combination)."""
|
||||
actions: list[str | None] = []
|
||||
categories: list[str] = []
|
||||
for field, raw in details:
|
||||
if field == "Action":
|
||||
value = _unquote(raw)
|
||||
if value and value not in actions:
|
||||
actions.append(value)
|
||||
elif field == "Category":
|
||||
value = _unquote(raw)
|
||||
if value and value not in categories:
|
||||
categories.append(value)
|
||||
if not actions:
|
||||
# No Action: detail. The group key is the action ONLY when it is
|
||||
# action-shaped; otherwise the candidate keeps action None rather than
|
||||
# inventing one from a sub-heading.
|
||||
actions = [entry.group_action]
|
||||
|
||||
specs = _data_specs(details) or [(None, None, None, None)]
|
||||
candidates: list[IntentCandidate] = []
|
||||
for action in actions:
|
||||
for scheme, authority, path, path_kind in specs:
|
||||
candidates.append(
|
||||
IntentCandidate(
|
||||
component=entry.component,
|
||||
action=action,
|
||||
categories=tuple(categories),
|
||||
scheme=scheme,
|
||||
authority=authority,
|
||||
path=path,
|
||||
path_kind=path_kind,
|
||||
likely_internal=is_likely_internal(action, entry.component),
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def rank_candidates(
|
||||
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> list[IntentCandidate]:
|
||||
"""Deduplicate, order deterministically, and cap (t16).
|
||||
|
||||
Order: candidates a user is likely to want first (``likely_internal`` last),
|
||||
then the dedup key itself — action, scheme, authority, path, component,
|
||||
categories — so the order is total and identical on a re-scan, which is what
|
||||
makes it safe for a UI to key rows by position.
|
||||
|
||||
The cap (``limit``, default ``INTENT_CANDIDATE_LIMIT``) keeps a pathological
|
||||
table from becoming a wall of rows; it is applied AFTER dedup, drops the
|
||||
least interesting rows first (internal-looking ones) and is DISCLOSED by the
|
||||
caller (``IntentParseResult.truncated``), never silent.
|
||||
"""
|
||||
unique: dict[tuple, IntentCandidate] = {}
|
||||
for candidate in candidates:
|
||||
unique.setdefault(candidate.key, candidate)
|
||||
ordered = sorted(
|
||||
unique.values(),
|
||||
key=lambda c: (
|
||||
c.likely_internal,
|
||||
c.action or "",
|
||||
c.scheme or "",
|
||||
c.authority or "",
|
||||
c.path or "",
|
||||
c.component,
|
||||
c.categories,
|
||||
),
|
||||
)
|
||||
return ordered[:limit]
|
||||
|
||||
|
||||
def parse_intent_report(
|
||||
output: str | None,
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> IntentParseResult:
|
||||
"""Parse a resolver capture into a report: candidates, count, capped or not.
|
||||
|
||||
An absent section yields an EMPTY report, not an answer — callers must ask
|
||||
``has_activity_resolver_table`` (the manager does) before treating "no
|
||||
candidates" as "this app advertises nothing".
|
||||
"""
|
||||
if not output:
|
||||
return IntentParseResult()
|
||||
section = activity_resolver_section(output)
|
||||
if section is None:
|
||||
return IntentParseResult()
|
||||
|
||||
raw: list[IntentCandidate] = []
|
||||
for entry, details in _iter_filters(section):
|
||||
raw.extend(_candidates_for(entry, details))
|
||||
ranked = rank_candidates(raw, limit=limit)
|
||||
deduped = len({candidate.key for candidate in raw})
|
||||
return IntentParseResult(
|
||||
candidates=tuple(ranked),
|
||||
found=deduped,
|
||||
truncated=deduped > len(ranked),
|
||||
)
|
||||
|
||||
|
||||
def parse_activity_resolver_table(
|
||||
output: str | None,
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> list[IntentCandidate]:
|
||||
"""The candidates in a ``pm dump`` resolver capture (t16).
|
||||
|
||||
Deduplicated on (component, action, categories, scheme, authority, path),
|
||||
deterministically ordered and capped — see ``rank_candidates``. An absent
|
||||
section returns an empty list: this function reports WHAT IS THERE, and the
|
||||
caller decides whether the capture was an answer at all (the manager checks
|
||||
``has_activity_resolver_table`` first — a failed scan is not an answer and is
|
||||
never cached as one).
|
||||
"""
|
||||
return list(parse_intent_report(output, limit=limit).candidates)
|
||||
|
||||
|
||||
def intent_scan_command(package: str, *, line_limit: int = INTENT_SCAN_LINE_LIMIT) -> str:
|
||||
"""The ON-DEVICE filter: only the resolver section crosses the wire (t16).
|
||||
|
||||
``pm dump <pkg>`` is a whole dumpsys (~2241 lines for one app on the user's
|
||||
Shield), so the range is applied by the device's own ``sed`` and the answer is
|
||||
additionally bounded, which is what makes the transfer proportional to the
|
||||
resolver table rather than to the dump.
|
||||
|
||||
SAFETY: the package id is the only caller-supplied text in the command and it
|
||||
is always ``shlex.quote``d; the sed script is built from the two constants
|
||||
above (no interpolation of anything untrusted), so a package id containing
|
||||
shell metacharacters cannot add a command — the fallback builder is quoted the
|
||||
same way. A caller must still refuse an id that is not package-shaped: the
|
||||
manager does (``is_launchable``).
|
||||
"""
|
||||
sed_range = f"/{SED_RANGE_START}/,/{SED_RANGE_END}/p"
|
||||
return (
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | "
|
||||
f'sed -n "{sed_range}" | '
|
||||
f"head -n {int(line_limit)}"
|
||||
)
|
||||
|
||||
|
||||
def intent_scan_fallback_command(
|
||||
package: str,
|
||||
*,
|
||||
line_limit: int = INTENT_SCAN_LINE_LIMIT,
|
||||
) -> str:
|
||||
"""The bounded ``grep -A`` fallback for a device without sed ranges (t16).
|
||||
|
||||
``pm dump <pkg> 2>/dev/null | grep -A <cap> -m 1 -F 'Activity Resolver Table:'``
|
||||
— the same transfer bound as the primary command, applied with the one tool
|
||||
every Android toolbox has. It is only ever a SECOND attempt: the manager runs
|
||||
it when the primary answer did not contain the section at all, so a device
|
||||
whose sed works never pays for it.
|
||||
"""
|
||||
return (
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | "
|
||||
f"grep -A {int(line_limit)} -m 1 -F {shlex.quote(ACTIVITY_TABLE_HEADER)}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentCacheEntry:
|
||||
"""One cached ANSWER: its state, its candidates and when it was written."""
|
||||
|
||||
state: str
|
||||
candidates: tuple[IntentCandidate, ...]
|
||||
stamp: float
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class IntentScanCache:
|
||||
"""A per-package TTL cache of scans that RAN (t16).
|
||||
|
||||
The rule the discovery-cache defect taught us is structural here: ``put`` is
|
||||
the ONLY writer, it is called only by a scan that ran, and it records which
|
||||
of the two answers it holds — ``INTENT_ENTRY_CANDIDATES`` or
|
||||
``INTENT_ENTRY_EMPTY``. A scan whose transport failed never reaches ``put``,
|
||||
so nothing is cached and the next request retries instead of serving a
|
||||
fabricated "this app advertises nothing" for the whole TTL.
|
||||
|
||||
A stale entry is DROPPED when it is read (never served), so callers see
|
||||
either an answer inside its TTL or nothing at all.
|
||||
"""
|
||||
|
||||
def __init__(self, *, ttl: float = INTENT_SCAN_TTL, clock=time.monotonic):
|
||||
self._ttl = ttl
|
||||
self._clock = clock
|
||||
self._entries: dict[str, IntentCacheEntry] = {}
|
||||
|
||||
@property
|
||||
def ttl(self) -> float:
|
||||
return self._ttl
|
||||
|
||||
def get(self, package: str) -> IntentCacheEntry | None:
|
||||
"""The fresh entry for a package, or None (dropping it when stale)."""
|
||||
entry = self._entries.get(package)
|
||||
if entry is None:
|
||||
return None
|
||||
if self._clock() - entry.stamp > self._ttl:
|
||||
self._entries.pop(package, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def put(
|
||||
self,
|
||||
package: str,
|
||||
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
|
||||
*,
|
||||
truncated: bool = False,
|
||||
) -> IntentCacheEntry:
|
||||
"""Record a scan that RAN: its candidates, or the empty ANSWER.
|
||||
|
||||
The state is derived, not passed: an empty candidate list means the
|
||||
device answered with none (``INTENT_ENTRY_EMPTY``) — an answer worth
|
||||
caching — while a non-empty list is ``INTENT_ENTRY_CANDIDATES``.
|
||||
"""
|
||||
saved = tuple(candidates)
|
||||
entry = IntentCacheEntry(
|
||||
state=INTENT_ENTRY_CANDIDATES if saved else INTENT_ENTRY_EMPTY,
|
||||
candidates=saved,
|
||||
stamp=self._clock(),
|
||||
truncated=truncated,
|
||||
)
|
||||
self._entries[package] = entry
|
||||
return entry
|
||||
|
||||
def state(self, package: str | None) -> str | None:
|
||||
"""The cached entry's state, or None when no fresh answer is cached."""
|
||||
if not package:
|
||||
return None
|
||||
entry = self.get(package)
|
||||
return entry.state if entry is not None else None
|
||||
|
||||
def age(self, package: str) -> float | None:
|
||||
"""How long ago the fresh entry was written, or None."""
|
||||
entry = self.get(package)
|
||||
return None if entry is None else self._clock() - entry.stamp
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Forget every entry (tests, and a device switch)."""
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentScanResult:
|
||||
"""The answer to one scan request (t16), status included.
|
||||
|
||||
``candidates`` is only ever an ANSWER when ``answered`` is True. Every other
|
||||
status means we do not know, which is why they all carry no candidates: an
|
||||
ADB-down scan must not look like an app with no intents.
|
||||
"""
|
||||
|
||||
package: str
|
||||
status: str
|
||||
candidates: tuple[IntentCandidate, ...] = ()
|
||||
entry_state: str | None = None
|
||||
age_seconds: float | None = None
|
||||
truncated: bool = False
|
||||
|
||||
@property
|
||||
def answered(self) -> bool:
|
||||
"""Did the DEVICE answer? False means "unknown", never "no intents"."""
|
||||
return self.status in _ANSWERED_STATUSES
|
||||
|
||||
@property
|
||||
def has_candidates(self) -> bool:
|
||||
"""Did the device answer WITH intents?"""
|
||||
return bool(self.candidates)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""A plain dict, for the options page, diagnostics and JSON."""
|
||||
return {
|
||||
"package": self.package,
|
||||
"status": self.status,
|
||||
"answered": self.answered,
|
||||
"entry_state": self.entry_state,
|
||||
"age_seconds": self.age_seconds,
|
||||
"truncated": self.truncated,
|
||||
"candidates": [candidate.as_dict() for candidate in self.candidates],
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Screen-capture image entity — an OPT-IN diagnostic surface (t66).
|
||||
|
||||
The user asked for "an image entity of the device screen capture? Can that be
|
||||
added". This is that entity, and it is the only thing in the integration that
|
||||
grabs the screen. Three boundaries are deliberate and load-bearing:
|
||||
|
||||
* **It never drives state.** An image entity's state IS its
|
||||
``image_last_updated`` timestamp, and nothing here writes to the manager's
|
||||
store, to the power ladder or to any other entity. A capture cannot make the
|
||||
media player, the sensors or the power switch claim something the device did
|
||||
not report. (The user's original constraint: screen capture must never drive
|
||||
state.)
|
||||
* **It is opt-in, default OFF** (``CONF_SCREEN_CAPTURE_ENABLED``). With the
|
||||
option absent ``async_setup_entry`` adds no entity at all.
|
||||
* **It never polls.** ``ImageEntity`` ships ``_attr_should_poll = False`` ("no
|
||||
need to poll image entities") and this module keeps it. Nothing runs on a
|
||||
timer; a capture happens only when something asks for the bytes (the image
|
||||
proxy serving ``entity_picture``) or when the user calls
|
||||
``homeassistant.update_entity``.
|
||||
|
||||
BINARY-SAFE CAPTURE — base64 over the shell, and why not a device file
|
||||
=====================================================================
|
||||
``AdbDevice.shell()`` returns ``str``: it decodes the shell channel as text, so
|
||||
raw PNG bytes cannot travel through it — ``\\x89PNG`` is not UTF-8 and the
|
||||
undecodable bytes would be mangled on the way out. There are two binary-safe
|
||||
routes and this module takes the SECOND:
|
||||
|
||||
1. ``screencap -p /sdcard/<name>.png`` then ``AdbDevice.pull()`` — the library's
|
||||
real binary path (the ADB sync protocol). It needs the raw ``AdbDevice``
|
||||
object, which the ADB client owns as a private attribute, and it writes to
|
||||
the device's shared storage: something must delete that file afterwards, and
|
||||
a crash between write and delete leaves a full-resolution screenshot behind
|
||||
on the user's device.
|
||||
2. ``screencap -p | base64`` then ``base64.b64decode`` locally. Base64 output is
|
||||
pure ASCII, so the shell's text decoding cannot corrupt it, and there is no
|
||||
device-side file at all — nothing to clean up, nothing to leak.
|
||||
|
||||
Route 2 costs ~33% more bytes on the socket than route 1, and one claim is
|
||||
INFERRED rather than measured: no Shield was available here, so how the
|
||||
persistent ADB socket handles a few hundred kilobytes of base64 in one
|
||||
``shell()`` call is untested on hardware (the README says so). It is still the
|
||||
right trade: it uses the PUBLIC seam the watchdog's socket already offers
|
||||
(``manager.adb_poller.shell``), leaves no artifact on the device, and its
|
||||
correctness on the wire is provable here with a real PNG fixture.
|
||||
|
||||
One capture costs exactly ONE shell, whatever the caller does: concurrent
|
||||
requests share a lock, and a successful capture is reused for
|
||||
``CAPTURE_REUSE_SECONDS`` so a dashboard repainting cannot hammer the device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import time
|
||||
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_SCREEN_CAPTURE_ENABLED, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: ``screencap -p`` writes PNG to stdout; ``base64`` makes it ASCII, which is the
|
||||
#: whole trick — see the module docstring.
|
||||
SCREEN_CAPTURE_COMMAND = "screencap -p | base64"
|
||||
|
||||
#: The PNG file signature. Nothing but real PNG bytes ever leaves this module.
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
#: A successful capture is reused for this long. Several image fetches in a row
|
||||
#: (a dashboard reload, two clients, a stream preview) must not become one screen
|
||||
#: grab each — "cannot hammer the device" is a property of the code, not a hope.
|
||||
CAPTURE_REUSE_SECONDS = 5.0
|
||||
|
||||
#: The reason the entity is (un)available, published as ``capture_status``.
|
||||
STATUS_OK = "ok"
|
||||
STATUS_ADB_DISABLED = "adb_disabled"
|
||||
STATUS_ADB_DISCONNECTED = "adb_disconnected"
|
||||
STATUS_CAPTURE_FAILED = "capture_failed"
|
||||
|
||||
#: The attribute HA reads beside the state — declared unrecorded (G12).
|
||||
ATTR_CAPTURE_STATUS = "capture_status"
|
||||
|
||||
|
||||
def decode_screen_capture(output: str | None) -> bytes | None:
|
||||
"""Decode one ``screencap -p | base64`` answer into PNG bytes, or None.
|
||||
|
||||
Pure on purpose, so the one part that can corrupt an image is testable
|
||||
without a device: the answer is base64 with ``base64``'s line wrapping (the
|
||||
whitespace is not part of the payload), the decoded bytes MUST carry the PNG
|
||||
signature, and every other outcome — empty output, a shell that never
|
||||
answered, a device that printed an error line instead — is a failure that
|
||||
returns None rather than a corrupt or zero-byte image.
|
||||
"""
|
||||
if not output:
|
||||
return None
|
||||
try:
|
||||
data = base64.b64decode("".join(output.split()), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
return None
|
||||
# The signature alone is not a PNG: the spec puts IHDR as the FIRST chunk,
|
||||
# immediately after it (length 13, type "IHDR"). A shell that returned the
|
||||
# signature plus junk — or a device that answered with a header in another
|
||||
# format — fails here. What CANNOT be detected locally is a truncation AFTER
|
||||
# the header: those bytes are still a well-formed PNG prefix, and the
|
||||
# consumer (HA's image proxy, then the browser) is what rejects them.
|
||||
if data[12:16] != b"IHDR":
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the screen-capture entity — only when the user opted in (t66)."""
|
||||
if not entry.options.get(CONF_SCREEN_CAPTURE_ENABLED, False):
|
||||
return # default OFF: no entity, no capture, no cost
|
||||
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
if manager is None:
|
||||
_LOGGER.debug("Screen capture skipped: no runtime manager on the entry")
|
||||
return
|
||||
|
||||
async_add_entities([NvidiaShieldScreenImage(hass, entry)])
|
||||
|
||||
|
||||
class NvidiaShieldScreenImage(ImageEntity):
|
||||
"""The Shield's current screen as a PNG — manual, diagnostic, never a source.
|
||||
|
||||
A capture is fetched on demand and nothing else: the entity publishes no
|
||||
command, writes no state source and is not polled. Its state is the
|
||||
timestamp of the last successful capture, and its ``capture_status``
|
||||
attribute names why it cannot serve an image when it cannot.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "screen_capture"
|
||||
_attr_content_type = "image/png"
|
||||
# HA's own default for image entities, kept explicitly: this entity must
|
||||
# never turn into a timer that grabs the screen every scan interval.
|
||||
_attr_should_poll = False
|
||||
# G12: a poll-adjacent value must not bloat the recorder database, and the
|
||||
# image bytes are never part of the state at all.
|
||||
_unrecorded_attributes = frozenset({ATTR_CAPTURE_STATUS})
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialise the entity over the entry's runtime manager.
|
||||
|
||||
The two-argument shape every platform in this integration uses: the
|
||||
manager travels on ``entry.runtime_data`` (t40's wiring), so the real-HA
|
||||
entity matrix can build this class exactly like the others.
|
||||
"""
|
||||
super().__init__(hass)
|
||||
# ImageEntity.__init__ builds HA's shared httpx client but does NOT set
|
||||
# ``hass`` (the platform does that in production). The rest of this
|
||||
# integration's entities assign it themselves, and without it HA's own
|
||||
# state write raises "Attribute hass is None" — measured in the real-HA
|
||||
# matrix, which builds entities the same way the tests do.
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._png: bytes | None = None
|
||||
self._captured_at: float = 0.0
|
||||
# One shell at a time, however many callers ask for the image.
|
||||
self._lock = asyncio.Lock()
|
||||
poller = self._adb_poller()
|
||||
self._status = (
|
||||
STATUS_ADB_DISABLED
|
||||
if poller is None
|
||||
else STATUS_OK
|
||||
if poller.connected
|
||||
else STATUS_ADB_DISCONNECTED
|
||||
)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_screen_capture"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="NVIDIA",
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
# -- availability ------------------------------------------------------
|
||||
|
||||
def _adb_poller(self):
|
||||
"""The ADB client this entry owns, or None when ADB is off/not built."""
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
if manager is None:
|
||||
return None
|
||||
return getattr(manager, "adb_poller", None)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""True only while a capture can actually be served.
|
||||
|
||||
Unavailable is the honest answer for "ADB is off", "ADB is down" and "the
|
||||
last capture failed"; the alternative — being available and handing HA an
|
||||
empty image — is exactly the blank screenshot this must never produce.
|
||||
"""
|
||||
return self._status == STATUS_OK
|
||||
@property
|
||||
def capture_status(self) -> str:
|
||||
"""Why the entity is (un)available — the same value as the attribute."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The reason behind the availability, as a PROPERTY (HA reads attrs)."""
|
||||
return {ATTR_CAPTURE_STATUS: self._status}
|
||||
|
||||
# -- the capture path --------------------------------------------------
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return the PNG bytes, capturing on demand (the image proxy seam)."""
|
||||
return await self._async_capture()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Explicit refresh (``homeassistant.update_entity``) — skip the reuse.
|
||||
|
||||
This is also how a failed capture is retried: a failure makes the entity
|
||||
unavailable on purpose, and an explicit refresh is the user's way back
|
||||
without waiting for the next dashboard fetch.
|
||||
"""
|
||||
await self._async_capture(force=True)
|
||||
|
||||
async def _async_capture(self, *, force: bool = False) -> bytes | None:
|
||||
"""One screen grab, shared by everyone who asks within the reuse window."""
|
||||
async with self._lock:
|
||||
if (
|
||||
not force
|
||||
and self._png is not None
|
||||
and time.monotonic() - self._captured_at < CAPTURE_REUSE_SECONDS
|
||||
):
|
||||
return self._png
|
||||
|
||||
poller = self._adb_poller()
|
||||
if poller is None:
|
||||
return self._fail(STATUS_ADB_DISABLED)
|
||||
if not poller.connected:
|
||||
return self._fail(STATUS_ADB_DISCONNECTED)
|
||||
try:
|
||||
output = await poller.shell(SCREEN_CAPTURE_COMMAND)
|
||||
except Exception as err: # the transport is remote; it may raise anything
|
||||
# ``AdbClientBase.shell`` already turns its own failures into
|
||||
# None, so this is defence in depth — a raised error must still
|
||||
# end as "unavailable", never as a blank image in HA.
|
||||
_LOGGER.warning("Screen capture failed on %s: %s", self._entry.title, err)
|
||||
return self._fail(STATUS_CAPTURE_FAILED)
|
||||
data = decode_screen_capture(output)
|
||||
if data is None:
|
||||
# Never a blank or zero-byte image: no answer, a truncated one,
|
||||
# an error line or a non-PNG all land here.
|
||||
_LOGGER.warning(
|
||||
"Screen capture from %s did not return a PNG (%d bytes of "
|
||||
"shell output)",
|
||||
self._entry.title,
|
||||
len(output or ""),
|
||||
)
|
||||
return self._fail(STATUS_CAPTURE_FAILED)
|
||||
|
||||
self._png = data
|
||||
self._captured_at = time.monotonic()
|
||||
self._status = STATUS_OK
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
return data
|
||||
|
||||
def _fail(self, status: str) -> None:
|
||||
"""Record why no image can be served and stay unavailable."""
|
||||
self._status = status
|
||||
return None
|
||||
@@ -13,7 +13,7 @@
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "2026.10.12",
|
||||
"version": "2026.10.12.2",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,31 +9,58 @@ remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
||||
long presses are expressed as the START_LONG/END_LONG pair instead;
|
||||
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
||||
``hold_secs`` parameter;
|
||||
- ``activity`` = the protocol current-app push (package; name resolution is
|
||||
deferred app-map work);
|
||||
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
||||
toggling when the state already matches).
|
||||
- ``RemoteEntityFeature.ACTIVITY`` with an ``activity_list`` built from OUR app
|
||||
directory — the library exposes NO app-list API (measured), which is exactly why
|
||||
core's list is user-maintained too — so the app/activity picker matches core;
|
||||
- ``current_activity`` resolves the foreground push (protocol first, ADB parse as
|
||||
the fallback) through the one ``resolve_app_name`` chain, and stays None while
|
||||
nothing is known: no fabricated activity, ever;
|
||||
- ``turn_on(activity=...)`` wakes when the device says it is off and then launches
|
||||
through the same path ``select_source`` uses; an activity name the directory does
|
||||
not carry is a ``ServiceValidationError``, never a silent no-op;
|
||||
- the transport's REAL state is published in ``extra_state_attributes`` so a
|
||||
never-connected transport cannot look like a paired one (no ``is_paired`` exists:
|
||||
a session or a push is the only proof);
|
||||
- a configured command script still owns turn_on/turn_off and never touches the
|
||||
protocol, so a press that worked through it says NOTHING about pairing — the
|
||||
attributes name which route each power command takes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY,
|
||||
RemoteEntity,
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_APP_NAME_MAP,
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
command_scripts,
|
||||
remote_command_script,
|
||||
)
|
||||
from .media_player import async_call_script, async_run_command_script, entity_available
|
||||
from .helpers import protocol_cert_paths
|
||||
from .media_player import (
|
||||
activity_names,
|
||||
app_directory,
|
||||
async_call_script,
|
||||
async_run_command_script,
|
||||
entity_available,
|
||||
resolve_app_name,
|
||||
)
|
||||
|
||||
_START_PREFIX = "START_LONG:"
|
||||
_END_PREFIX = "END_LONG:"
|
||||
@@ -64,6 +91,19 @@ def _key_code_name(command: str) -> str:
|
||||
return upper # unknown names: the library reports/validates them
|
||||
|
||||
|
||||
def _looks_launchable(value: str) -> bool:
|
||||
"""True for a package id or a detail link — forms the launch path accepts (t15).
|
||||
|
||||
The rule is the repo's own definition of a package (``_valid_app_row``: dotted,
|
||||
no spaces) plus URL-shaped deep links, which is exactly what
|
||||
``send_launch_app_command`` handles. It is deliberately NOT a guess that an id
|
||||
exists: the device answers that. It exists so "Netflx" (a typo of a listed name)
|
||||
is refused while an app the directory does not carry can still be launched.
|
||||
"""
|
||||
if not value or " " in value:
|
||||
return False
|
||||
return "." in value or "://" in value
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -74,11 +114,21 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
"""Remote entity over the manager's androidtvremote2 transport."""
|
||||
"""Remote entity over the manager's androidtvremote2 transport.
|
||||
|
||||
t15: this is the entity the app/activity picker hangs off. HA renders the picker
|
||||
only when ``supported_features`` carries ``RemoteEntityFeature.ACTIVITY`` — the
|
||||
user's "the UI doesn't actually show any buttons like the android tv remote
|
||||
does" — and the list itself has to come from OUR directory, because
|
||||
``AndroidTVRemote`` exposes no app-list API at all (measured on the installed
|
||||
library; core androidtv_remote keeps a user-maintained map for the same reason).
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
#: The feature flag that makes HA offer the activity/app picker at all.
|
||||
_attr_supported_features = RemoteEntityFeature.ACTIVITY
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
@@ -87,10 +137,58 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
entry, "runtime_data", None
|
||||
)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
||||
# t15: the activity list is available from the FIRST render (a real-HA state
|
||||
# write needs no entity platform), and is refreshed with discovered apps once
|
||||
# ADB can answer (``async_added_to_hass``).
|
||||
self._attr_activity_list = activity_names(self._entry.options)
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
#: Which protocol pushes have EVER arrived — the only pairing evidence the
|
||||
#: library can offer (it has no is_paired). Sticky on purpose: the store
|
||||
#: expires every field after STATE_STORE_TTL, so "has it ever arrived?"
|
||||
#: cannot be read from the store.
|
||||
self._protocol_pushes_seen: set[str] = set()
|
||||
self._note_protocol_pushes()
|
||||
#: "present"/"missing"/"unknown" — the shared setup certificate, which is
|
||||
#: why enabling the transport on a paired entry asks for no new PIN.
|
||||
self._protocol_certificate = "unknown"
|
||||
self._activity_refresh_task = None
|
||||
|
||||
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||
return getattr(self._entry, "runtime_data", None)
|
||||
|
||||
def _resolve_activity_name(self) -> str | None:
|
||||
"""The foreground app as a friendly name (t15), or None when nothing is known.
|
||||
|
||||
The same chain as the media player's ``app_name`` and the Current App sensor
|
||||
(``resolve_app_name``): the user's map, then the cached device label, then the
|
||||
curated table, then the raw id — so an activity and a source can never
|
||||
disagree about what is on screen. The foreground identity prefers the protocol
|
||||
push and falls back to the ADB parse, which keeps this entity useful on an
|
||||
ADB-only setup (its protocol surface alone degrades).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return None
|
||||
return resolve_app_name(
|
||||
manager.foreground_app,
|
||||
self._entry.options.get(CONF_APP_NAME_MAP) or {},
|
||||
manager.app_label,
|
||||
)
|
||||
|
||||
def _note_protocol_pushes(self) -> None:
|
||||
"""Remember which device pushes have arrived (t15) — sticky, see __init__."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if manager.current_app is not None:
|
||||
self._protocol_pushes_seen.add("current_app")
|
||||
if manager.is_on is not None:
|
||||
self._protocol_pushes_seen.add("is_on")
|
||||
if manager.is_available is not None:
|
||||
self._protocol_pushes_seen.add("is_available")
|
||||
if manager.volume_info is not None:
|
||||
self._protocol_pushes_seen.add("volume")
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
@@ -113,25 +211,166 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
"""Current foreground app reported by the protocol push."""
|
||||
"""The raw foreground package (protocol push; ADB parse as the fallback).
|
||||
|
||||
Kept alongside HA's ``current_activity``: this is the id (what
|
||||
``turn_on(activity=...)`` accepts and what the Current App sensor stamps),
|
||||
while ``current_activity`` is the friendly name HA displays.
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
return manager.current_app
|
||||
return manager.foreground_app
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The transport's real state — the pairing answer, readable from HA (t15).
|
||||
|
||||
There is no "is it paired?" API to call (the library exposes none either,
|
||||
measured), so everything here is a fact that can be PROVEN:
|
||||
|
||||
* ``protocol_state`` — ``off`` / ``never_connected`` / ``connected`` /
|
||||
``disconnected`` / ``auth_rejected``. ``never_connected`` is the state
|
||||
that used to be permanent (``keep_reconnecting()`` alone never connects),
|
||||
and it must never look like ``connected``.
|
||||
* ``protocol_connected`` / ``protocol_ever_authenticated`` — a session or a
|
||||
device push is the only proof of an authenticated (paired) client.
|
||||
* ``protocol_current_app_seen`` / ``protocol_is_on_seen`` /
|
||||
``protocol_volume_seen`` — whether each device push has EVER arrived.
|
||||
* ``protocol_last_error`` / ``protocol_failures`` — a send that could not
|
||||
run is recorded here and logged, never swallowed into a debug line. The
|
||||
ERROR is the failure the CURRENT state has not recovered from (t20/O1):
|
||||
it is cleared as soon as a session is proven again — the next connect or
|
||||
the transport's own availability push — so it can never read as a
|
||||
problem beside ``protocol_connected: true``, and a failure that happened
|
||||
AFTER the session came up is kept because that one is still current.
|
||||
``protocol_failures`` keeps counting, so a recovery stays visible as
|
||||
history instead of as a stale message.
|
||||
* ``protocol_certificate`` — the setup pairing's shared ``client.pem`` is
|
||||
still on disk, which is exactly why enabling the transport asks for no
|
||||
new PIN (the other Android TV integration pairs its own client).
|
||||
* ``power_on_route`` / ``power_off_route`` — ``script:<id>`` when a command
|
||||
script owns that command. That path never touches the protocol, so a
|
||||
success through it says NOTHING about pairing.
|
||||
"""
|
||||
return {
|
||||
"activity": self.activity,
|
||||
# t15: which tier produced it — a protocol push must never win SILENTLY
|
||||
# over the ADB parse the user's setup is built on (see the manager's
|
||||
# ``foreground_source``).
|
||||
"activity_source": self._protocol_read("foreground_source"),
|
||||
"power_on_route": self._power_route("turn_on"),
|
||||
"power_off_route": self._power_route("turn_off"),
|
||||
"protocol_active": self._protocol_active(),
|
||||
"protocol_state": self._protocol_state(),
|
||||
"protocol_connected": self._protocol_flag("protocol_connected"),
|
||||
"protocol_ever_authenticated": self._protocol_flag(
|
||||
"protocol_ever_authenticated"
|
||||
),
|
||||
"protocol_last_error": self._protocol_read("protocol_last_error"),
|
||||
"protocol_failures": self._protocol_read("protocol_failures"),
|
||||
"protocol_certificate": self._protocol_certificate,
|
||||
"protocol_current_app_seen": "current_app" in self._protocol_pushes_seen,
|
||||
"protocol_is_on_seen": "is_on" in self._protocol_pushes_seen,
|
||||
"protocol_volume_seen": "volume" in self._protocol_pushes_seen,
|
||||
}
|
||||
|
||||
def _protocol_read(self, name: str, default=None):
|
||||
"""Read one transport fact off the manager, or the default (stub managers)."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return default
|
||||
return getattr(manager, name, default)
|
||||
|
||||
def _protocol_active(self) -> bool:
|
||||
return bool(self._protocol_read("protocol_active", False))
|
||||
|
||||
def _protocol_state(self) -> str:
|
||||
"""The transport state, with ``off`` for a manager that carries no client."""
|
||||
if not self._protocol_active():
|
||||
return "off"
|
||||
return self._protocol_read("protocol_state", "never_connected")
|
||||
|
||||
def _protocol_flag(self, name: str) -> bool:
|
||||
return bool(self._protocol_read(name, False))
|
||||
|
||||
def _power_route(self, command: str) -> str:
|
||||
"""Which path ``turn_on``/``turn_off`` takes: a script, or the protocol (t15).
|
||||
|
||||
A configured command script owns the sequence and never touches the
|
||||
protocol — so "the power press worked" through it is NOT evidence that the
|
||||
transport is paired, or even connected. That is the conclusion the user drew
|
||||
and then doubted, made readable instead of inferred.
|
||||
"""
|
||||
script = (command_scripts(self._entry.options) or {}).get(command)
|
||||
return f"script:{script}" if script else "protocol"
|
||||
|
||||
|
||||
@callback
|
||||
def _refresh_state(self) -> None:
|
||||
"""Manager store changed — re-evaluate state (activity/available)."""
|
||||
"""Manager store changed — re-resolve activity, availability, transport."""
|
||||
self._manager = self._manager_of()
|
||||
self._note_protocol_pushes()
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
self._manager_unregistered = manager.register_listener(
|
||||
self._refresh_state
|
||||
self._manager_unregistered = manager.register_listener(self._refresh_state)
|
||||
self._note_protocol_pushes()
|
||||
await self._async_note_certificate()
|
||||
self._schedule_activity_refresh()
|
||||
|
||||
async def _async_note_certificate(self) -> None:
|
||||
"""Record whether the shared pairing certificate exists (t15).
|
||||
|
||||
This is the "no new PIN is expected" evidence, and it is a design fact, not a
|
||||
guess: ``protocol_cert_paths`` is shared between the config flow's pairing step
|
||||
and this runtime, so an entry paired at setup reuses that ``client.pem`` when
|
||||
the transport is (re-)enabled. The OTHER Android TV integration pairs its own
|
||||
client and shares nothing with us.
|
||||
"""
|
||||
add_executor_job = getattr(self.hass, "async_add_executor_job", None)
|
||||
if add_executor_job is None:
|
||||
return # a hass stand-in: leave it "unknown" rather than guess
|
||||
try:
|
||||
certfile, keyfile = await add_executor_job(protocol_cert_paths, self.hass)
|
||||
except Exception: # pragma: no cover - a filesystem we cannot read
|
||||
return
|
||||
self._protocol_certificate = (
|
||||
"present" if os.path.exists(certfile) and os.path.exists(keyfile) else "missing"
|
||||
)
|
||||
|
||||
def _schedule_activity_refresh(self) -> None:
|
||||
"""Add the apps ADB discovery actually found to the activity list (t15).
|
||||
|
||||
One bounded sweep, on demand — the t57 path the options editor uses — never a
|
||||
per-state-write shell. The list is already complete for a configured
|
||||
directory, and a sweep that cannot run changes nothing (no fabrication).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
create_task = getattr(self.hass, "async_create_task", None)
|
||||
if create_task is None or not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if not callable(getattr(manager, "async_refresh_installed_apps", None)):
|
||||
return # a manager stand-in: the directory alone is the honest list
|
||||
self._activity_refresh_task = create_task(
|
||||
self._async_refresh_activity_list(manager),
|
||||
"nvidia_shield_remote_activities",
|
||||
)
|
||||
|
||||
async def _async_refresh_activity_list(self, manager) -> None:
|
||||
"""Fold discovered apps into the activity list (t15) — one write if it grew."""
|
||||
try:
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
except Exception: # pragma: no cover - discovery never breaks the entity
|
||||
return
|
||||
names = activity_names(self._entry.options, rows)
|
||||
if names != self._attr_activity_list:
|
||||
self._attr_activity_list = names
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
unregistered = getattr(self, "_manager_unregistered", None)
|
||||
if unregistered is not None:
|
||||
@@ -139,9 +378,31 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
self._manager_unregistered = None
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Wake the device — via the configured script, else the POWER key."""
|
||||
# t44: `turn_on` is one configuration, and the remote entity is the other
|
||||
# route to it; a script that owns the power sequence owns it here too.
|
||||
"""Wake the device, and launch a requested activity (t15).
|
||||
|
||||
With NO activity — or an empty one — this behaves exactly as before: the
|
||||
configured script owns the wake sequence when there is one, otherwise POWER
|
||||
goes out only when the device says it is off (unknown counts as "maybe on",
|
||||
so nothing is toggled blindly).
|
||||
|
||||
With ``activity``, the same wake happens first (a script that owns turn_on
|
||||
still owns it — it never touches the protocol), and then the app is launched
|
||||
over the protocol through the same path ``select_source`` uses. A name the
|
||||
activity list does not carry, and that is not a package/detail link either,
|
||||
raises ``ServiceValidationError``: an activity request that cannot launch
|
||||
must never look like it worked.
|
||||
"""
|
||||
activity = str(kwargs.get(ATTR_ACTIVITY) or "").strip()
|
||||
await self._async_wake()
|
||||
if activity:
|
||||
await self._async_launch_activity(activity)
|
||||
|
||||
async def _async_wake(self) -> None:
|
||||
"""Turn the device on: the configured script first, else the POWER key (t44).
|
||||
|
||||
t44: `turn_on` is one configuration, and the remote entity is the other route
|
||||
to it; a script that owns the power sequence owns it here too.
|
||||
"""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
@@ -152,6 +413,51 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
return
|
||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||
|
||||
async def _async_launch_activity(self, activity: str) -> None:
|
||||
"""Launch an activity through the shared launch path (t15).
|
||||
|
||||
The target comes from the same directory the activity list is built from, so
|
||||
anything the picker offers resolves. A package id or a deep link passes
|
||||
through: that is how an app the directory does not carry can still be
|
||||
launched, and what core does with an activity it does not know. Anything else
|
||||
is a request nothing can launch and raises instead of doing nothing.
|
||||
"""
|
||||
target = self._activity_target(activity)
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="protocol_unavailable",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
if not await manager.launch_app(target):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="activity_launch_failed",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
def _activity_target(self, activity: str) -> str:
|
||||
"""Activity name (or id) → what the launch path takes (t15).
|
||||
|
||||
A listed NAME wins, compared case-insensitively — the same rule the
|
||||
``source_list``/``select_source`` pair uses, so the picker can never offer a
|
||||
name this does not resolve. A ``CONF_APP_NAME_MAP``-less entry still resolves
|
||||
the curated names, because both read the one ``app_directory``.
|
||||
"""
|
||||
directory = app_directory(self._entry.options)
|
||||
wanted = activity.casefold()
|
||||
for package, name in directory.items():
|
||||
if name.casefold() == wanted:
|
||||
return package
|
||||
if activity in directory or _looks_launchable(activity):
|
||||
return activity
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_activity",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Sleep the device — via the configured script, else the POWER key."""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
|
||||
|
||||
@@ -124,9 +124,10 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
"""The resolved friendly name (t40), not the raw package id.
|
||||
|
||||
Exactly the ``media_player.app_name`` chain via the shared
|
||||
``resolve_app_name``: apps map → cached manifest label → the package
|
||||
text itself. A bare or unknown package shows its package text rather
|
||||
than None, like the media player's app_name.
|
||||
``resolve_app_name``: apps map → cached device label → the built-in app
|
||||
names (t66) → the package text itself. A bare or unknown package shows its
|
||||
package text rather than None, like the media player's app_name, and that
|
||||
text is the id ONCE — never a ``pkg=pkg`` pair.
|
||||
|
||||
This is why ``_package`` must be the CURRENT app and the label fetch
|
||||
must be driven off it (t55): the device-manifest label is resolved on
|
||||
@@ -299,23 +300,24 @@ class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||
return {"playback_state": "idle"}
|
||||
|
||||
attrs = state.attributes
|
||||
result = {
|
||||
"playback_state": state.state,
|
||||
"title": attrs.get("media_title"),
|
||||
"media_type": attrs.get("media_content_type"),
|
||||
}
|
||||
|
||||
if attrs.get("media_series_title"):
|
||||
result["series"] = attrs["media_series_title"]
|
||||
if attrs.get("media_season") is not None:
|
||||
result["season"] = attrs["media_season"]
|
||||
if attrs.get("media_episode") is not None:
|
||||
result["episode"] = attrs["media_episode"]
|
||||
if attrs.get("media_duration") is not None:
|
||||
result["duration"] = attrs["media_duration"]
|
||||
if attrs.get("media_position") is not None:
|
||||
result["position"] = attrs["media_position"]
|
||||
if attrs.get("entity_picture"):
|
||||
result["thumbnail"] = attrs["entity_picture"]
|
||||
# t65: the same "no None rows" rule the media player's flattened surface
|
||||
# follows — an attribute that reads `title: null` in the template editor
|
||||
# is noise, so a field the Jellyfin entity did not answer is OMITTED
|
||||
# rather than published. Zero stays: 0 s of position is a real reading.
|
||||
result = {"playback_state": state.state}
|
||||
for key, source in (
|
||||
("title", "media_title"),
|
||||
("media_type", "media_content_type"),
|
||||
("series", "media_series_title"),
|
||||
("season", "media_season"),
|
||||
("episode", "media_episode"),
|
||||
("duration", "media_duration"),
|
||||
("position", "media_position"),
|
||||
("thumbnail", "entity_picture"),
|
||||
):
|
||||
value = attrs.get(source)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
@@ -120,8 +120,13 @@
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -190,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": {
|
||||
@@ -204,6 +210,7 @@
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"logcat_events": "Watch app launches over logcat",
|
||||
"adb_command_enabled": "Allow the adb_command action",
|
||||
"screen_capture_enabled": "Add a screen capture image entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"power_entity": "Power switch"
|
||||
},
|
||||
@@ -215,6 +222,7 @@
|
||||
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
|
||||
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
|
||||
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
|
||||
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection."
|
||||
}
|
||||
@@ -233,7 +241,18 @@
|
||||
},
|
||||
"apps": {
|
||||
"title": "Apps",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
@@ -241,6 +260,60 @@
|
||||
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"title": "Scripts",
|
||||
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||
@@ -261,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -275,7 +428,25 @@
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Nvidia Shield media player to target."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,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",
|
||||
@@ -300,6 +471,62 @@
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
},
|
||||
"no_target": {
|
||||
"title": "No Shield selected",
|
||||
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
|
||||
},
|
||||
"ambiguous_target": {
|
||||
"title": "More than one Shield selected",
|
||||
"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",
|
||||
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
|
||||
},
|
||||
"activity_launch_failed": {
|
||||
"title": "Activity could not launch",
|
||||
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
|
||||
},
|
||||
"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": {
|
||||
@@ -313,6 +540,11 @@
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"screen_capture": {
|
||||
"name": "Screen capture"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,13 @@
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -190,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": {
|
||||
@@ -204,6 +210,7 @@
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"logcat_events": "Watch app launches over logcat",
|
||||
"adb_command_enabled": "Allow the adb_command action",
|
||||
"screen_capture_enabled": "Add a screen capture image entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"power_entity": "Power switch"
|
||||
},
|
||||
@@ -215,6 +222,7 @@
|
||||
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
|
||||
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
|
||||
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
|
||||
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection."
|
||||
}
|
||||
@@ -233,7 +241,18 @@
|
||||
},
|
||||
"apps": {
|
||||
"title": "Apps",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
@@ -241,6 +260,60 @@
|
||||
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"title": "Scripts",
|
||||
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||
@@ -261,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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -275,7 +428,25 @@
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Nvidia Shield media player to target."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,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",
|
||||
@@ -300,6 +471,62 @@
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
},
|
||||
"no_target": {
|
||||
"title": "No Shield selected",
|
||||
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
|
||||
},
|
||||
"ambiguous_target": {
|
||||
"title": "More than one Shield selected",
|
||||
"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",
|
||||
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
|
||||
},
|
||||
"activity_launch_failed": {
|
||||
"title": "Activity could not launch",
|
||||
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
|
||||
},
|
||||
"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": {
|
||||
@@ -313,6 +540,11 @@
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"screen_capture": {
|
||||
"name": "Screen capture"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
# Intent catalogue and registered actions (plan)
|
||||
|
||||
Scope: let the user discover the Android **intents** an installed app advertises, register
|
||||
the ones worth having, and fire them from Home Assistant — one button per registered
|
||||
intent, plus a `send_intent` action for the ones that need inputs.
|
||||
|
||||
Status: design agreed in principle by the user (2026-10-12). Not built. Companion docs:
|
||||
`docs/MVP_PLAN.md` (architecture), `docs/RESEARCH_SHIELD.md` (device captures),
|
||||
`docs/RESEARCH_IDEAS.md` (adjacent ideas).
|
||||
|
||||
**Decisions locked (user):**
|
||||
|
||||
- Registered **buttons**, set up inside our options menu, plus a **`send_intent`** action.
|
||||
Verbatim: *"The registered buttons, pre-setup within our menu options and a send_intent
|
||||
command seem the best method here. Less messy than the current adb command method of
|
||||
doing that."*
|
||||
- Discovery is **explicit** (the user scans an app), never an automatic sweep.
|
||||
- YAML stays **seed-only** (the standing ruling: we load it only when no UI config exists).
|
||||
|
||||
---
|
||||
|
||||
## 1. Where we are today
|
||||
|
||||
| Surface | State |
|
||||
|---|---|
|
||||
| `media_player.play_media` with a URL | Works — sends a `VIEW` intent through the ADB path |
|
||||
| `nvidia_shield.adb_command` | Works but is a raw escape hatch, and until t14 cannot even be aimed at the Shield from the UI |
|
||||
| Intent **discovery** | Does not exist |
|
||||
| Intent **registration** | Does not exist |
|
||||
| `ha_adb_common/intent.py` | **Not Android intents** — it is `PlaybackIntent`, the optimistic-UI dataclass. The name collision is a readability trap worth fixing (§7.4) |
|
||||
|
||||
So the user's memory of "scanning the available commands for ADB between applications" is
|
||||
a real gap, not something built and forgotten.
|
||||
|
||||
### What the device actually exposes (observed, one Shield, Android 11)
|
||||
|
||||
From the user's capture of `pm dump com.netflix.ninja` on 2026-10-12:
|
||||
|
||||
- the output is ~**2241 lines** and is a *whole* dumpsys — `DUMP OF SERVICE package:`,
|
||||
then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`;
|
||||
- the package section begins with an **`Activity Resolver Table:`** listing, per
|
||||
component/filter, the `Action:`, `Category:`, `Scheme:`, `Authority:` and `Path:` lines,
|
||||
followed by `Receiver Resolver Table:` and `Service Resolver Table:`;
|
||||
- Netflix's table contains real, usable entries (`cast:`, `http`/`https` with
|
||||
`www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` + `LEANBACK_LAUNCHER`) and a
|
||||
large amount of internal noise (`com.netflix.action.DEBUG_*`, `androidx.*`,
|
||||
`com.google.firebase.*`, `ConstraintProxy*`).
|
||||
|
||||
Everything in §2 depends on that shape. It is **observed on one firmware**, so the parser
|
||||
must degrade honestly rather than assume.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discovery
|
||||
|
||||
Source of truth: the app's **activity resolver table**.
|
||||
|
||||
Filter **on the device** — on this Shield an unfiltered `pm dump` ships 2241 lines over
|
||||
ADB for one app:
|
||||
|
||||
```sh
|
||||
pm dump <pkg> | sed -n '/Activity Resolver Table/,/Service Resolver Table/p'
|
||||
```
|
||||
|
||||
with a bounded `grep -A` fallback when the device's `sed`/toolbox cannot do ranges. Keep
|
||||
the transfer to the resolver section; the rest of the dump is never needed.
|
||||
|
||||
Rules:
|
||||
|
||||
- **One package at a time, on demand**, triggered by the user from the Intents sub-screen.
|
||||
Never a sweep of every installed app (that is 20 × ~2241 lines on this device).
|
||||
- **Cached** per package with a TTL, in the same spirit as the app-label cache; a scan
|
||||
that *ran* and found nothing is a real answer, a scan whose transport failed is not
|
||||
(the lesson from the discovery-cache defect).
|
||||
- **Rank, do not hide.** Flag internal-looking filters (`DEBUG_*`, `androidx.*`,
|
||||
`com.google.firebase.*`) rather than deleting them — the user decides what is worth
|
||||
registering, and we do not get to guess.
|
||||
|
||||
A cheaper action-first query also exists (`cmd package query-activities --brief -a …`).
|
||||
**Not verified on this device**; treat as a possible optimisation, not the primary path.
|
||||
|
||||
Candidates are deduplicated on (component, action, categories, scheme/authority/path).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model — a registered intent
|
||||
|
||||
```yaml
|
||||
registered_intents:
|
||||
- id: netflix_title # slug; also the button's key
|
||||
name: Netflix — open title # what the user sees
|
||||
package: com.netflix.ninja
|
||||
activity: .MainActivity # either a component…
|
||||
action: android.intent.action.VIEW # …or an action (+ categories)
|
||||
categories: [android.intent.category.DEFAULT]
|
||||
data: "https://www.netflix.com/watch/{id}" # URI template, optional
|
||||
fields: # declared inputs, with defaults
|
||||
- {key: id, type: string, required: true}
|
||||
```
|
||||
|
||||
Mapping to `am start` (through the existing ADB path):
|
||||
|
||||
| Declared | `am start` |
|
||||
|---|---|
|
||||
| component | `-n com.netflix.ninja/.MainActivity` |
|
||||
| action | `-a android.intent.action.VIEW` |
|
||||
| categories | `-c …` (repeated) |
|
||||
| data template | `-d <resolved>` |
|
||||
| `type: string` | `--es key value` |
|
||||
| `type: int` | `--ei key value` |
|
||||
| `type: bool` | `--ez key value` |
|
||||
|
||||
Notes that matter:
|
||||
|
||||
- **No eval, no shell interpolation.** `{placeholder}` substitution is plain string
|
||||
replacement, and every value is `shlex.quote`d before it reaches the device.
|
||||
- A missing **required** field is a `ServiceValidationError` naming the field.
|
||||
- An optional `raw` field type passes extras verbatim for advanced cases, documented as
|
||||
**unvalidated** — the escape hatch, not the default.
|
||||
|
||||
---
|
||||
|
||||
## 4. Surfaces
|
||||
|
||||
1. **One button entity per registered intent** — `button.<name>`, pressing it runs the
|
||||
intent with the declared defaults. This is the "register an action for each one" the
|
||||
user asked for, and it is the whole point for no-input intents.
|
||||
2. **`nvidia_shield.send_intent`** — `intent: <name>` (a dropdown of registered intents)
|
||||
plus `variables: {…}` supplying that intent's declared fields. This is how per-intent
|
||||
inputs arrive from automations, scripts and dashboards; it mirrors `script.turn_on`
|
||||
with variables because HA offers no clean way to register a service per config entry.
|
||||
The action **returns the device's `am start` output**, so a failure is visible.
|
||||
3. **Intents sub-screen** in the options flow, the same idiom as the Apps screen:
|
||||
*Add an intent* (pick package → pick a discovered candidate or enter one manually →
|
||||
name it → declare its fields), *Edit*, *Remove*, *Scan an app's intents*.
|
||||
4. **YAML seeding** — a `registered_intents:` block, honoured only when the UI holds
|
||||
nothing (seed-only ruling).
|
||||
|
||||
---
|
||||
|
||||
## 5. Failure modes and honesty
|
||||
|
||||
- `am start` failures (unresolved intent, permission denied, activity gone after an app
|
||||
update) **surface the device's own message** and raise; never a silent success.
|
||||
- **ADB is required.** The protocol transport cannot send intents; when ADB is down the
|
||||
error says so instead of failing obscurely.
|
||||
- An unknown intent name raises, listing the known names.
|
||||
- An intent that the app rejects is a **config** problem, and the docs say so plainly.
|
||||
- Nothing registered ever runs by itself, and discovery never auto-registers anything.
|
||||
|
||||
---
|
||||
|
||||
## 6. Costs
|
||||
|
||||
- Discovery: one filtered dump per scanned package, cached.
|
||||
- Invocation: one ADB shell per press.
|
||||
- Storage: registered intents are ordinary options data.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. Does `send_intent` returning a response need `SupportsResponse.OPTIONAL` on our HA
|
||||
floor? Verify against the real installed HA before building on it.
|
||||
2. Typed fields (string/int/bool) for v1, or string-plus-raw only? Recommendation: typed,
|
||||
since that is what makes the dropdown-plus-variables model pleasant.
|
||||
3. Broadcasts/receivers (`am broadcast`) — the tables are right there — are **out of scope
|
||||
for v1**; activities and actions only.
|
||||
4. Rename `ha_adb_common/intent.py` → `playback_intent.py` so the new `intents.py` is not
|
||||
confused with it? Small churn, real clarity gain.
|
||||
5. Should discovery be exposed as a service as well as a UI screen? Recommendation: UI
|
||||
only in v1.
|
||||
6. `play_media(media_content_type="intent")`? Recommendation: **no** — the button and the
|
||||
action are clearer, and play_media already carries the URL/VIEW case.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed tickets
|
||||
|
||||
| Unit | Contents |
|
||||
|---|---|
|
||||
| Discovery | `ha_adb_common/intents.py` + the filtered on-device dump + parser + cache + tests, with a fixture cut from the user's real Netflix capture |
|
||||
| Registration & invocation | Intents sub-screen, per-intent fields, button entities, `send_intent` action, mapping, docs, strings, tests |
|
||||
| Review | Adversarial verification of both, including a rejected-intent and an ADB-down path |
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-goals
|
||||
|
||||
- No automatic intent execution or auto-registration from discovery.
|
||||
- No arbitrary shell passthrough in `send_intent` (`adb_command` remains that door).
|
||||
- No broadcasts/receivers in v1.
|
||||
- No extras beyond string/int/bool/raw.
|
||||
- Not a replacement for the app source list or `play_media`.
|
||||
@@ -404,7 +404,7 @@ covers what the external pieces provided:
|
||||
|
||||
| External piece | What it provided | Now native (file) |
|
||||
|---|---|---|
|
||||
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
|
||||
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the reading is published as separate top-level `now_playing_*` attributes, and `now_playing_via` names the tier (`jellyfin` \| `media_session` \| `app`) |
|
||||
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
|
||||
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
|
||||
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
|
||||
|
||||
+246
-1
@@ -7,6 +7,7 @@ their entity module through the synthetic ``nsh`` package AFTER calling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import enum
|
||||
import sys
|
||||
import types
|
||||
@@ -25,7 +26,10 @@ ENTITIES_STUB_NAMES = (
|
||||
"homeassistant.components.media_player",
|
||||
"homeassistant.components.remote",
|
||||
"homeassistant.components.button",
|
||||
"homeassistant.components.image",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.service",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
@@ -33,14 +37,19 @@ ENTITIES_STUB_NAMES = (
|
||||
"homeassistant.helpers.redact",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.entity_platform",
|
||||
"homeassistant.util",
|
||||
"homeassistant.util.dt",
|
||||
"nsh.helpers",
|
||||
"nsh.power",
|
||||
"nsh.jellyfin",
|
||||
"nsh.media_player",
|
||||
"nsh.diagnostics",
|
||||
"nsh.remote",
|
||||
"nsh.image",
|
||||
)
|
||||
|
||||
#: t14: every runtime service description registered through the stub face below,
|
||||
#: as ``(domain, service, schema)``. A test clears it to see only its own call.
|
||||
SERVICE_SCHEMA_CALLS: list = []
|
||||
|
||||
class EntitySelectorStub:
|
||||
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
||||
@@ -80,6 +89,30 @@ def install_selector_faces(selector_module) -> None:
|
||||
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||
selector_module.SelectSelectorConfig = _Config
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
# t64: the apps table is a MULTILINE text field, and the row editor picks an
|
||||
# app out of a select whose options are label/value pairs. Both are real HA
|
||||
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
|
||||
selector_module.SelectOptionDict = dict
|
||||
selector_module.TextSelector = TextSelectorStub
|
||||
selector_module.TextSelectorConfig = _Config
|
||||
|
||||
|
||||
class TextSelectorStub:
|
||||
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
|
||||
|
||||
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
|
||||
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
|
||||
the browser render a textarea, which is the t64 fix, so it is inspectable
|
||||
here the same way the entity selectors' domains are.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("Value is not a string")
|
||||
return data
|
||||
|
||||
|
||||
class MediaPlayerState(enum.StrEnum):
|
||||
@@ -210,7 +243,40 @@ class MediaPlayerEntity:
|
||||
return None
|
||||
|
||||
|
||||
class RemoteEntityFeature(enum.IntFlag):
|
||||
"""homeassistant.components.remote.RemoteEntityFeature (the real values)."""
|
||||
|
||||
LEARN_COMMAND = 1
|
||||
DELETE_COMMAND = 2
|
||||
ACTIVITY = 4
|
||||
|
||||
|
||||
class RemoteEntity:
|
||||
"""The RemoteEntity surface HA reads, backed by the ``_attr_*`` convention.
|
||||
|
||||
t15: HA renders the app/activity picker off ``supported_features`` and reads
|
||||
``activity_list``/``current_activity``; both are cached properties on the real
|
||||
base, reading ``_attr_*``. The stub carries that contract (the same shape as the
|
||||
ImageEntity face above) so a test can read what the entity publishes instead of
|
||||
asserting against our own attribute names.
|
||||
"""
|
||||
|
||||
_attr_activity_list: list | None = None
|
||||
_attr_current_activity: str | None = None
|
||||
_attr_supported_features = RemoteEntityFeature(0)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def activity_list(self):
|
||||
return self._attr_activity_list
|
||||
|
||||
@property
|
||||
def current_activity(self):
|
||||
return self._attr_current_activity
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
return None
|
||||
|
||||
@@ -391,6 +457,12 @@ def install() -> None:
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
core.CALLBACK_TYPE = object
|
||||
# t14: the adb_command registration asks for OPTIONAL response support (the
|
||||
# handler returns the command's output). The stub keeps HA's three values
|
||||
# distinct so a test can tell "optional" from "only".
|
||||
core.SupportsResponse = types.SimpleNamespace(
|
||||
NONE="none", OPTIONAL="optional", ONLY="only"
|
||||
)
|
||||
core.Event = object
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
@@ -402,11 +474,17 @@ def install() -> None:
|
||||
# two switch states have to exist with their real values.
|
||||
ha_const.STATE_ON = "on"
|
||||
ha_const.STATE_OFF = "off"
|
||||
# t14: the adb_command target arrives as these two service-call fields (the UI's
|
||||
# target picker merges the selection in as entity_id, an automation targeting a
|
||||
# device sends device_id), so the entity service schema names them.
|
||||
ha_const.ATTR_ENTITY_ID = "entity_id"
|
||||
ha_const.ATTR_DEVICE_ID = "device_id"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
REMOTE="remote",
|
||||
BUTTON="button",
|
||||
IMAGE="image",
|
||||
)
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
@@ -429,6 +507,14 @@ def install() -> None:
|
||||
|
||||
remote = types.ModuleType("homeassistant.components.remote")
|
||||
remote.RemoteEntity = RemoteEntity
|
||||
# t15: the feature flag that makes HA offer the activity/app picker, the service
|
||||
# field the picker submits, and the two attributes HA reads off the base class.
|
||||
remote.RemoteEntityFeature = RemoteEntityFeature
|
||||
remote.ATTR_ACTIVITY = "activity"
|
||||
remote.RemoteEntityStateAttribute = types.SimpleNamespace(
|
||||
ACTIVITY_LIST="activity_list",
|
||||
CURRENT_ACTIVITY="current_activity",
|
||||
)
|
||||
sys.modules["homeassistant.components.remote"] = remote
|
||||
|
||||
button = types.ModuleType("homeassistant.components.button")
|
||||
@@ -439,17 +525,172 @@ def install() -> None:
|
||||
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
|
||||
sys.modules["homeassistant.components.sensor"] = sensor_platform
|
||||
|
||||
# t66: homeassistant.components.image.ImageEntity — the slice the screen
|
||||
# capture entity uses, carrying the REAL contract that matters here: the
|
||||
# attributes HA reads (``content_type``, ``image_last_updated``), the real
|
||||
# ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the
|
||||
# bytes seam, state writing as a no-op (the harness writes no states), and
|
||||
# HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image
|
||||
# entities"). A subclass that forgets its base ``__init__`` fails here too.
|
||||
image_platform = types.ModuleType("homeassistant.components.image")
|
||||
|
||||
class _EntityFace:
|
||||
"""The Entity surface HA reads, backed by the ``_attr_*`` convention.
|
||||
|
||||
The real ImageEntity inherits all of this from Entity; the stub carries
|
||||
the properties so a test can READ what the entity publishes (naming,
|
||||
device, polling, availability) without a state machine.
|
||||
"""
|
||||
|
||||
_attr_available = True
|
||||
_attr_should_poll = True
|
||||
_attr_translation_key = None
|
||||
_attr_unique_id = None
|
||||
_attr_device_info = None
|
||||
_attr_extra_state_attributes = None
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._attr_available
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return self._attr_should_poll
|
||||
|
||||
@property
|
||||
def translation_key(self):
|
||||
return self._attr_translation_key
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return self._attr_device_info
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
class ImageEntity(_EntityFace):
|
||||
_attr_content_type = "image/jpeg"
|
||||
_attr_image_last_updated = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass, verify_ssl: bool = False) -> None:
|
||||
self.hass = hass
|
||||
|
||||
@property
|
||||
def content_type(self) -> str:
|
||||
return self._attr_content_type
|
||||
|
||||
@property
|
||||
def image_last_updated(self):
|
||||
return self._attr_image_last_updated
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
raise NotImplementedError
|
||||
|
||||
image_platform.ImageEntity = ImageEntity
|
||||
sys.modules["homeassistant.components.image"] = image_platform
|
||||
|
||||
# t66: homeassistant.util.dt — the timestamp helper the image entity stamps
|
||||
# ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA.
|
||||
ha_util = types.ModuleType("homeassistant.util")
|
||||
ha_util.__path__ = []
|
||||
dt_util = types.ModuleType("homeassistant.util.dt")
|
||||
|
||||
def _utcnow():
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
dt_util.utcnow = _utcnow
|
||||
ha_util.dt = dt_util
|
||||
sys.modules["homeassistant.util"] = ha_util
|
||||
sys.modules["homeassistant.util.dt"] = dt_util
|
||||
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
|
||||
# t14: ``async_set_service_schema`` publishes the runtime service DESCRIPTION —
|
||||
# the thing the frontend renders the target picker from (HA's cache is what
|
||||
# get_services serves). The stub records the call instead, so a test can assert
|
||||
# what the integration told HA to offer for nvidia_shield.adb_command.
|
||||
service = types.ModuleType("homeassistant.helpers.service")
|
||||
|
||||
def _stub_set_service_schema(hass, domain, service_name, schema):
|
||||
SERVICE_SCHEMA_CALLS.append((domain, service_name, schema))
|
||||
|
||||
service.async_set_service_schema = _stub_set_service_schema
|
||||
helpers.service = service
|
||||
sys.modules["homeassistant.helpers.service"] = service
|
||||
# t14: the entity service schema names its two target fields through
|
||||
# ``cv.entity_ids`` / ``cv.ensure_list_csv``. The stub keeps HA's SHAPE (None ->
|
||||
# [], text or list -> list, comma text -> split) without the real entity-id
|
||||
# format check — that check belongs to the installed HA and is exercised by
|
||||
# tests/test_adb_command_real_ha.py. The schema is not executed in the stub
|
||||
# suite anyway: those tests call the handler directly.
|
||||
config_validation = types.ModuleType("homeassistant.helpers.config_validation")
|
||||
|
||||
def _ensure_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
def _ensure_list_csv(value):
|
||||
if isinstance(value, str):
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
return _ensure_list(value)
|
||||
|
||||
config_validation.ensure_list = _ensure_list
|
||||
config_validation.ensure_list_csv = _ensure_list_csv
|
||||
config_validation.entity_ids = _ensure_list_csv
|
||||
helpers.config_validation = config_validation
|
||||
sys.modules["homeassistant.helpers.config_validation"] = config_validation
|
||||
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _RegistryEntry:
|
||||
"""The two attributes the integration reads off a registry entry (t14)."""
|
||||
|
||||
def __init__(self, entity_id):
|
||||
self.entity_id = entity_id
|
||||
self.domain = entity_id.split(".", 1)[0]
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
#: t14: ``device_id -> [entity_id, ...]``, what HA's registry knows about a
|
||||
#: device. Seeded by the test that needs a device target; ``async_get``
|
||||
#: keeps returning a fresh instance, so the binding lives on the class.
|
||||
bindings: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
def _stub_entries_for_device(registry, device_id, include_disabled_entities=False):
|
||||
"""t14: HA's device->entities helper is a MODULE-LEVEL function, not a method
|
||||
on the registry (measured on 2026.10.1) — the stub mirrors that shape, because
|
||||
a stub that put it on the class hid the AttributeError the real API raised.
|
||||
"""
|
||||
del registry, include_disabled_entities
|
||||
return [_RegistryEntry(entry) for entry in _Registry.bindings.get(device_id, [])]
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
entity_registry.async_entries_for_device = _stub_entries_for_device
|
||||
|
||||
|
||||
# t14: how a test gives the stub registry a device -> entities binding (the real
|
||||
# one is filled by HA's own entity registry) and how it takes it back out.
|
||||
def _stub_bind_device(device_id, *entity_ids):
|
||||
_Registry.bindings[device_id] = list(entity_ids)
|
||||
|
||||
entity_registry.bind_device = _stub_bind_device
|
||||
entity_registry.reset_bindings = lambda: _Registry.bindings.clear()
|
||||
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||
|
||||
class _DeviceRegistry:
|
||||
@@ -593,6 +834,9 @@ def load_entity_modules() -> dict:
|
||||
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
|
||||
# is what keeps this list from having to be complete.
|
||||
"button",
|
||||
# t66: the screen-capture platform is a platform like the others, so it
|
||||
# loads here too (and the module-coverage sweep already walks every file)
|
||||
"image",
|
||||
"init",
|
||||
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||
if name == "init" and "nsh.init" in sys.modules:
|
||||
@@ -609,6 +853,7 @@ def load_entity_modules() -> dict:
|
||||
"media_player",
|
||||
"remote",
|
||||
"diagnostics",
|
||||
"image",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
DUMP OF SERVICE package:
|
||||
Activity Resolver Table:
|
||||
Schemes:
|
||||
cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "com.google.cast.action.START"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "cast"
|
||||
http: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Category: "android.intent.category.BROWSABLE"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
Scheme: "http"
|
||||
Authority: "www.netflix.com": -1
|
||||
Path: "PatternMatcher{GLOB: /title.*}"
|
||||
Path: "PatternMatcher{GLOB: /watch.*}"
|
||||
Path: "PatternMatcher{LITERAL, /browse}"
|
||||
Path: "PatternMatcher{LITERAL, /home}"
|
||||
Path: "PatternMatcher{GLOB: /deeplink.*}"
|
||||
https: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Category: "android.intent.category.BROWSABLE"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
Scheme: "https"
|
||||
Authority: "www.netflix.com": -1
|
||||
Path: "PatternMatcher{GLOB: /title.*}"
|
||||
Path: "PatternMatcher{GLOB: /watch.*}"
|
||||
Path: "PatternMatcher{LITERAL, /browse}"
|
||||
Path: "PatternMatcher{LITERAL, /home}"
|
||||
Path: "PatternMatcher{GLOB: /deeplink.*}"
|
||||
netflix: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "netflix"
|
||||
nflx: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "nflx"
|
||||
Non-Data Actions:
|
||||
android.intent.action.MAIN:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a2
|
||||
Action: "android.intent.action.MAIN"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
com.netflix.action.NETFLIX_KEY_START:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a3
|
||||
Action: "com.netflix.action.NETFLIX_KEY_START"
|
||||
com.netflix.action.DIAL_START:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a4
|
||||
Action: "com.netflix.action.DIAL_START"
|
||||
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
|
||||
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a5
|
||||
Action: "com.netflix.action.DEBUG_PUSH_NOTIFICATION"
|
||||
com.netflix.action.DEBUG_FORCE_EXIT:
|
||||
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a6
|
||||
Action: "com.netflix.action.DEBUG_FORCE_EXIT"
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
androidx.work.DiagnosticsReportReceiver:
|
||||
deca7fa com.netflix.ninja/androidx.work.impl.diagnostics.DiagnosticsReceiver
|
||||
com.google.firebase.MESSAGING_EVENT:
|
||||
deca7fa com.netflix.ninja/com.google.firebase.messaging.FirebaseMessagingService
|
||||
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
|
||||
deca7fa com.netflix.ninja/.push.DebugPushReceiver
|
||||
Service Resolver Table:
|
||||
Non-Data Actions:
|
||||
com.google.firebase.INSTANCE_ID_EVENT:
|
||||
deca7fa com.netflix.ninja/com.google.firebase.iid.FirebaseInstanceIdService
|
||||
Provider Resolver Table:
|
||||
Authorities:
|
||||
com.netflix.ninja.androidx-startup:
|
||||
deca7fa com.netflix.ninja/androidx.startup.InitializationProvider
|
||||
Permissions:
|
||||
Permission [com.netflix.ninja.C2D_MESSAGE]:
|
||||
sourcePackage=com.netflix.ninja
|
||||
Packages:
|
||||
Package [com.netflix.ninja] (7f1a2b3c):
|
||||
userId=10123
|
||||
pkg=Package{7f1a2b3c com.netflix.ninja}
|
||||
nonLocalizedLabel=null
|
||||
labelRes=0x7f0f0d31
|
||||
versionCode=61102 minSdk=21 targetSdk=29
|
||||
versionName=6.11.2 build 3413
|
||||
codePath=/data/app/com.netflix.ninja-1/base.apk
|
||||
ConstraintProxy.BatteryBroadcastReceiver=
|
||||
com.netflix.ninja/androidx.work.impl.constraints.trackers.BatteryChargingProxy
|
||||
Activity Resolver Table: (nothing else listed for this package)
|
||||
DUMP OF SERVICE activity:
|
||||
ACTIVITY MANAGER PENDING INTENTS (dumpsys activity pending)
|
||||
(nothing)
|
||||
DUMP OF SERVICE meminfo:
|
||||
Applications Memory Usage (in Kilobytes):
|
||||
DUMP OF SERVICE procstats:
|
||||
Process Stats:
|
||||
DUMP OF SERVICE usagestats:
|
||||
In-memory rollup:
|
||||
DUMP OF SERVICE batterystats:
|
||||
Battery Stats:
|
||||
+118
-9
@@ -124,19 +124,97 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
|
||||
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
|
||||
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
|
||||
# t14: THE TARGET-BEARING REGISTRATION. `ServiceRegistry.async_register` takes no
|
||||
# `target` (measured on 2026.10.1, and the reason the user's UI offered no Shield
|
||||
# to select): the frontend's picker is rendered from the service DESCRIPTION,
|
||||
# which `async_set_service_schema` publishes and which WINS over a services.yaml.
|
||||
# Both names are resolved against the INSTALLED HA at scan time, so an API that
|
||||
# is renamed or dropped is a finding here, not a log line on a user's install.
|
||||
"async_register": ("homeassistant.core", "ServiceRegistry"),
|
||||
# NOTE the owner is the MODULE, not a class: the description writer and the
|
||||
# device-expansion helper are module-level functions. That matters — the
|
||||
# device path first shipped as `registry.async_entries_for_device(...)`, which is
|
||||
# a method that does not exist; the real-HA probe caught it, and this entry makes
|
||||
# the API's existence a scanned fact instead of a runtime AttributeError.
|
||||
"async_set_service_schema": ("homeassistant.helpers", "service"),
|
||||
"async_entries_for_device": ("homeassistant.helpers", "entity_registry"),
|
||||
}
|
||||
|
||||
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
|
||||
#: and the pytest wrapper (t39 F1). They sit below the current walk (24 modules,
|
||||
#: 58 imports, 179 relative imports, 46 checked arguments, 7 awaited HA calls,
|
||||
#: 1102 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
||||
#: and the pytest wrapper (t39 F1). They sit below the current walk (26 modules,
|
||||
#: 77 imports, 282 relative imports, 65 checked arguments, 6 awaited HA calls,
|
||||
#: 1661 statement blocks) so a broken or mis-targeted walk fails loudly instead of
|
||||
#: printing zeros and exiting 0.
|
||||
#:
|
||||
#: THE MARGIN POLICY (t20/O3 — the t19 review asked for this in writing). A floor
|
||||
#: is a VACUITY-AND-BLINDNESS alarm, not a size ratchet: it must fail when the walk
|
||||
#: stops COVERING something (a broken glob, a regex that stopped matching, a module
|
||||
#: that stopped being parsed), and it must not fail because a legitimate change
|
||||
#: removed code. The margins follow from that, per counter:
|
||||
#:
|
||||
#: * counters where ONE unit is one API OWNER (modules, imports, call_args): 2.
|
||||
#: One owner may be refactored away; a whole CLASS of owners going unseen (the
|
||||
#: t62 F2 case, where deleting `async_show_menu` from HA_CALL_OWNERS left the
|
||||
#: sweep green) is what the 2 catches. This is the project's long-standing
|
||||
#: canary (t62 wrote it as "two below the count"), now stated once instead of
|
||||
#: being implied by each raise.
|
||||
#: * relative_imports: ~2.5%. The count is dominated by package-relative imports
|
||||
#: that legitimately churn when files move; a collapse (an unparsed module, a
|
||||
#: stale glob) is orders of magnitude, not a handful.
|
||||
#: * statement_blocks: ~2%. This counter moves in BULK with every edit, which is
|
||||
#: exactly why a fixed small margin would be wrong here (the t19 complaint: at
|
||||
#: 1300 vs 1322 a 23-statement refactor tripped it). A 2% band leaves a normal
|
||||
#: refactor alone while a parse failure still shows up as a large drop.
|
||||
#:
|
||||
#: No band states a COUNT: the current walk is in the header above and printed by
|
||||
#: every sweep run, so a figure in this prose can only rot — a previous revision
|
||||
#: carried exactly such a figure for statement_blocks and it was stale within one
|
||||
#: unit of work (t24/O3). The BANDS are the policy and are deliberately the only
|
||||
#: numbers here; a floor, once re-derived from a re-measured walk, is named next to
|
||||
#: the counter that owns it.
|
||||
#: * awaited_calls: 1 (measured 6, floor 5 — DELIBERATE, see below). Stated
|
||||
#: plainly: with six awaited HA calls a percentage margin is meaningless, so
|
||||
#: this floor is the weakest one here. It is still worth having — a walk that
|
||||
#: stopped visiting `await` sites at all trips it — but a deliberate removal of
|
||||
#: an awaited call MUST lower this floor in the same commit, and that is the
|
||||
#: reviewable act the gate exists for.
|
||||
#:
|
||||
#: t18/F3 asked whether these margins are deliberate and whether the counters were
|
||||
#: genuinely updated rather than dodged. Both answers are yes, and they are recorded
|
||||
#: HERE rather than in a task log: the t18 review measured awaited_calls at 6
|
||||
#: against this floor of 5 and three other counters within 2 (modules 26/24,
|
||||
#: imports 77/75, call_args 65/63 — each named in its own comment below), and the
|
||||
#: verdict is that those ARE the policy margins above: 2 for owner-counters because
|
||||
#: an owner really can be refactored away, 1 for awaited_calls because the counter
|
||||
#: is too small to do better. The counters themselves were RAISED to the real sweep
|
||||
#: when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
|
||||
#: twice since (t20: 1620→1615; t21: 1615→1617 after this unit's blocks), never
|
||||
#: lowered to dodge a failure and never left above the walk.
|
||||
#:
|
||||
#: The rule for changing any of them: lower a floor in the SAME commit that removes
|
||||
#: the code it counts, and never raise one without re-running the sweep first (a
|
||||
#: floor above the walk is a gate that can never pass).
|
||||
FLOORS: dict[str, int] = {
|
||||
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
||||
# count (13) — a regression to the shallow glob trips it.
|
||||
"modules": 20,
|
||||
"imports": 40,
|
||||
"relative_imports": 100,
|
||||
# t17: raised 20 -> 24, just under the current 26.
|
||||
"modules": 24,
|
||||
# t15: raised from 40/100 to just under the current 76/206, because the remote
|
||||
# parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`,
|
||||
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
|
||||
# helpers all arrive as imports, and none of them may vanish unnoticed.
|
||||
# t17: raised 70 -> 75, just under the current 77 (the intents work imports
|
||||
# the scan/status vocabulary into the flow and the engine into the platform).
|
||||
"imports": 75,
|
||||
# t16: raised 190 -> 195 -> 235, again just under the walk. The intent
|
||||
# discovery module adds the package's exports to `ha_adb_common`'s surface and
|
||||
# is imported through it by the manager, so the relative-import count is exactly
|
||||
# where a new module must show up — a floor left behind would let the manager's
|
||||
# import of it disappear unnoticed.
|
||||
# t17: raised 235 -> 275, just under the current 282. The registered-intent
|
||||
# model, its statuses and its helpers move between modules, so this is again
|
||||
# exactly where a detached import must show up.
|
||||
"relative_imports": 275,
|
||||
# t62 F2: raised from 20 to just under the current 46. A floor's job is to
|
||||
# notice an API OWNER going away, not only a broken walk: at 20, deleting
|
||||
# `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still
|
||||
@@ -144,11 +222,42 @@ FLOORS: dict[str, int] = {
|
||||
# carries a real settlement argument is load-bearing (show_form, show_menu,
|
||||
# abort, create_entry, update_entry). The floor sits two below the count so a
|
||||
# one-call-site refactor does not redden the gate.
|
||||
"call_args": 44,
|
||||
#
|
||||
# t14: raised from 44 to just under the current 54, because the target-bearing
|
||||
# registration added two owners (`async_register`'s `schema`/`supports_response`
|
||||
# arguments and the module-level description writer). At 44 those owners could go
|
||||
# away without the gate noticing; at 52 they are load-bearing.
|
||||
#
|
||||
# t15: raised from 52 to just under the current 57 — the options-flow pairing
|
||||
# step is a real flow-display surface of its own (its own async_show_form with
|
||||
# step_id/data_schema/errors), so those call sites must not be able to vanish.
|
||||
# t17: raised 55 -> 63, just under the current 65 — the Intents screen adds
|
||||
# real flow-display call sites of its own (its menu, its three forms and the
|
||||
# scan's result step), none of which may vanish unnoticed.
|
||||
"call_args": 63,
|
||||
"awaited_calls": 5,
|
||||
# t62 F2: the unreachable-statement pass walks ~1100 statement blocks. A
|
||||
# t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A
|
||||
# floor here means a pass that silently stopped parsing cannot look green.
|
||||
"statement_blocks": 500,
|
||||
# t15: raised 500 -> 1200 -> 1300, just under the walk, for the same reason the
|
||||
# imports floor moved: the exhaustion pass covers the new surfaces too.
|
||||
# t16: raised 1200 -> 1300 -> 1400: the parser, the cache and the manager's
|
||||
# scan path are new statement blocks, and the unreachable pass must keep
|
||||
# covering them.
|
||||
# t17: raised 1400 -> 1620, just under the current 1637. The screen, the seed
|
||||
# validator, the command builder and the button are new statement blocks, and
|
||||
# the unreachable pass is what keeps covering them — which is the point of the
|
||||
# floor: a pass that silently stopped parsing must not look green.
|
||||
# t20: the walk is 1648 after the protocol lifecycle fixes (the tracked
|
||||
# attempt, the cancel-and-await stop and the transition banner are new blocks),
|
||||
# so the floor is re-derived from the CURRENT walk at the 2% band above.
|
||||
# t21: the walk is 1650 (the ADB-off guard returns before the scan, and the
|
||||
# unterminated-value check is a few blocks more), so the 2% band is re-derived
|
||||
# again: 1617.
|
||||
# t23: the walk is 1661 — the ADB-off gate is asked by every device path now
|
||||
# (sweep, label probe, logcat, heartbeat, watchdog, delegation, the accessor),
|
||||
# which is a dozen more statement blocks. Re-derived at the same 2% band: 1627.
|
||||
# Raised, not lowered, because that is what the re-measured walk says.
|
||||
"statement_blocks": 1627,
|
||||
}
|
||||
|
||||
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""t14: `nvidia_shield.adb_command` against a REAL Home Assistant install.
|
||||
|
||||
The user's report was that the Shield could not be SELECTED for the action, so he
|
||||
ran his diagnostics through the deprecated ``androidtv`` integration. A stub suite
|
||||
cannot settle that: the picker is whatever HA hands the browser, and "was the
|
||||
command sent to the Shield I picked?" is decided by HA's own service registry,
|
||||
schema validation and target merging.
|
||||
|
||||
What this file drives, on the installed HA (2026.10.1):
|
||||
|
||||
* the REAL ``hass.services`` registry — the registration our ``async_setup`` makes;
|
||||
* the REAL websocket command the frontend calls (``get_services``), asserting the
|
||||
payload carries ``target: {entity: [{domain: media_player}]}`` — that payload IS
|
||||
the picker;
|
||||
* the REAL ``hass.services.async_call`` with ``return_response=True`` — asserting
|
||||
the output comes back under ``adb_response`` (the key the user's captures use)
|
||||
and that a call without it behaves as before;
|
||||
* the REAL entity registry, for the ``device_id`` shape an automation sends.
|
||||
|
||||
WHAT THIS FILE CANNOT PROVE: there is no browser here, so nothing about how the
|
||||
picker LOOKS. It proves the wire contract — the registration, the metadata the UI
|
||||
consumes, and the resolved call. Every test skips cleanly when homeassistant is not
|
||||
installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real service registry is the point of this file",
|
||||
)
|
||||
|
||||
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.exceptions import ServiceValidationError # noqa: E402
|
||||
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
||||
from homeassistant.helpers import entity_registry as er # noqa: E402
|
||||
from homeassistant.helpers.entity_registry import ( # noqa: E402
|
||||
EntityRegistryItems,
|
||||
EntityRegistrySettings,
|
||||
)
|
||||
from homeassistant.helpers.service import ( # noqa: E402
|
||||
async_get_all_descriptions,
|
||||
)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
SERVICE = "adb_command"
|
||||
ENTITY_ID = "media_player.shield"
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""The ADB surface the handler uses: one shell call, real values back."""
|
||||
|
||||
def __init__(self, output="ok", error=None):
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, command):
|
||||
self.calls.append(command)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.output
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with the real registries and the real service registry.
|
||||
|
||||
No core bootstrap: the registries are filled directly with the two containers
|
||||
core's loader fills (same objects), exactly as the t56 flow probe does — so an
|
||||
EntitySelector / target expansion sees the entities the real one would.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
registry = er.async_get(hass)
|
||||
registry.entities = EntityRegistryItems(hass)
|
||||
registry.deleted_entities = {}
|
||||
registry.settings = EntityRegistrySettings()
|
||||
registry._entities_data = registry.entities.data
|
||||
hass.data[er.DATA_REGISTRY] = registry
|
||||
# the real entry manager: device_registry.async_get_or_create needs an entry it
|
||||
# can look up, exactly as a real device registration does
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain=DOMAIN,
|
||||
title="Shield",
|
||||
data={},
|
||||
options={},
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
object.__setattr__(entry, "_options", {})
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
hass._shield_entry = entry
|
||||
return hass
|
||||
|
||||
|
||||
@pytest.fixture(name="integration")
|
||||
def integration_fixture():
|
||||
"""The integration package, imported by path under its real name."""
|
||||
return importlib.import_module("custom_components.nvidia_shield")
|
||||
|
||||
|
||||
@pytest.fixture(name="media_player_module")
|
||||
def media_player_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.media_player")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="shield")
|
||||
async def shield_fixture(ha, integration, media_player_module):
|
||||
"""Set the integration up, and register ONE Shield as an adb_command target.
|
||||
|
||||
Yields the fake manager behind that entity, so a test can see exactly how many
|
||||
commands reached the device.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
manager = SimpleNamespace(adb_poller=poller, connected=True)
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = manager
|
||||
try:
|
||||
yield poller
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
|
||||
def _service_payload(descriptions):
|
||||
return descriptions[DOMAIN][SERVICE]
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
"""The websocket connection shape ``handle_get_services`` needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: list[dict] = []
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def send_result(self, msg_id, payload):
|
||||
self.messages.append({"id": msg_id, "type": "result", "result": payload})
|
||||
|
||||
|
||||
async def test_the_registration_declares_a_response(ha, integration, shield):
|
||||
"""t14: a service that answers must say so — and OPTIONAL keeps callers free."""
|
||||
from homeassistant.core import SupportsResponse
|
||||
|
||||
service = ha.services.async_services()[DOMAIN][SERVICE]
|
||||
assert service.supports_response is SupportsResponse.OPTIONAL
|
||||
|
||||
|
||||
async def test_the_frontend_payload_carries_the_target_picker(ha, integration, shield):
|
||||
"""THE fix: what the browser receives for this action must offer the Shield.
|
||||
|
||||
Driven through the real websocket command (``get_services``), because that is
|
||||
the payload the UI builds its action form and target picker from.
|
||||
"""
|
||||
payload = await async_get_all_descriptions(ha)
|
||||
description = _service_payload(payload)
|
||||
|
||||
# the entity target the picker is rendered from
|
||||
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
# a response-bearing action: the UI can offer to return the output
|
||||
assert description["response"] == {"optional": True}
|
||||
# both fields survive the runtime description (the form would be empty without)
|
||||
assert set(description["fields"]) == {"command", "entity_id"}
|
||||
|
||||
# ...and it serialises as JSON, which is what actually crosses the wire
|
||||
assert json.loads(json.dumps(payload))[DOMAIN][SERVICE]["target"] == {
|
||||
"entity": [{"domain": ["media_player"]}]
|
||||
}
|
||||
|
||||
|
||||
async def test_the_websocket_command_serves_the_same_payload(ha, integration, shield):
|
||||
"""The exact command the frontend calls, not just the accessor behind it."""
|
||||
connection = RecordingConnection()
|
||||
# the command is decorated: it schedules the handler as a background task and
|
||||
# returns None, which is exactly how the frontend's request is served
|
||||
ws_commands.handle_get_services(ha, connection, {"id": 7, "type": "get_services"})
|
||||
await ha.async_block_till_done()
|
||||
|
||||
# HA serialises the response to JSON BYTES before it reaches the socket, so this
|
||||
# is the exact text the browser parses for the action form
|
||||
assert len(connection.messages) == 1
|
||||
message = json.loads(connection.messages[0])
|
||||
assert message["id"] == 7
|
||||
assert message["success"] is True
|
||||
served = message["result"][DOMAIN][SERVICE]
|
||||
assert served["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
assert served["response"] == {"optional": True}
|
||||
assert set(served["fields"]) == {"command", "entity_id"}
|
||||
|
||||
|
||||
async def test_a_call_returns_the_command_output(ha, integration, shield):
|
||||
"""The response the user's captures read: ``adb_response``, the old key."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo hi", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo hi"]
|
||||
|
||||
|
||||
async def test_a_call_from_the_ui_target_shape_returns_the_output(ha, integration, shield):
|
||||
"""HA merges a picked target into the call as a LIST — accept it."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo list", "entity_id": [ENTITY_ID]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo list"]
|
||||
|
||||
|
||||
async def test_a_call_without_the_response_still_works(ha, integration, shield):
|
||||
"""Existing YAML callers: same data, no ``return_response`` — unchanged."""
|
||||
result = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo old", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert shield.calls == ["echo old"]
|
||||
|
||||
|
||||
async def test_the_device_target_shape_resolves_through_the_registry(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""An automation that targets the DEVICE sends ``device_id``: resolve it."""
|
||||
device = device_registry.async_get(ha).async_get_or_create(
|
||||
config_entry_id=ha._shield_entry.entry_id,
|
||||
identifiers={(DOMAIN, "shield-device")},
|
||||
name="Shield",
|
||||
)
|
||||
er.async_get(ha).async_get_or_create(
|
||||
"media_player",
|
||||
DOMAIN,
|
||||
"uid_shield",
|
||||
device_id=device.id,
|
||||
suggested_object_id="shield",
|
||||
)
|
||||
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo device", "device_id": device.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo device"]
|
||||
|
||||
|
||||
async def test_no_target_is_an_error_naming_what_to_pass(ha, integration, shield):
|
||||
"""Never a silent no-op: HA's own validation is not the only guard."""
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN, SERVICE, {"command": "echo x"}, blocking=True
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "no_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_two_targets_send_nothing(ha, integration, shield, media_player_module):
|
||||
"""A raw shell command is never broadcast to Shields the user did not pick."""
|
||||
other = SimpleNamespace(adb_poller=FakePoller(), connected=True)
|
||||
media_player_module._ADB_COMMAND_TARGETS["media_player.other"] = other
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": [ENTITY_ID, "media_player.other"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "ambiguous_target"
|
||||
assert shield.calls == []
|
||||
assert other.adb_poller.calls == []
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop("media_player.other", None)
|
||||
|
||||
|
||||
async def test_an_unknown_entity_is_an_error(ha, integration, shield):
|
||||
"""Another integration's media player is not a Shield to run shell on."""
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": "media_player.someone_else"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "unsupported_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_an_unreachable_shield_is_an_error_not_an_empty_response(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""The acceptance's honesty clause: ADB down → error, not ``None`` back."""
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=None, connected=False
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert err.value.translation_key == "adb_unavailable"
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
|
||||
async def test_a_failing_transport_surfaces_the_error(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""A transport failure mid-call propagates — never ``adb_response: None``."""
|
||||
poller = FakePoller(error=ConnectionError("adb socket dead"))
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=poller, connected=True
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ConnectionError):
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""t14: the adb_command ACTION is selectable — the contract HA reads.
|
||||
|
||||
The user's report was that the Shield could not be selected for the
|
||||
``nvidia_shield.adb_command`` action, so he ran his diagnostics through the
|
||||
deprecated ``androidtv`` integration instead. The cause was measured against the
|
||||
installed Home Assistant (2026.10.1), not guessed:
|
||||
|
||||
* ``ServiceRegistry.async_register`` takes no ``target`` — a target CANNOT be
|
||||
declared at registration;
|
||||
* the frontend's target picker comes from the service DESCRIPTION, which HA builds
|
||||
from ``services.yaml`` or from ``homeassistant.helpers.service.async_set_service_schema``,
|
||||
and the runtime description WINS;
|
||||
* therefore THIS integration must publish a description carrying
|
||||
``target: {entity: [{domain: media_player}]}`` — that is the picker.
|
||||
|
||||
This file pins the published contract itself, without any HA installed: the
|
||||
description, and the copy in BOTH translation files. The registration and the
|
||||
resolution are exercised in ``test_command_routing.py::TestAdbCommandService``
|
||||
(stubs) and in ``tests/test_adb_command_real_ha.py`` (real HA).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SHIELD = _ROOT / "custom_components" / "nvidia_shield"
|
||||
|
||||
|
||||
@pytest.fixture(name="const")
|
||||
def const_fixture():
|
||||
"""The integration's const module, loaded by path like every other test."""
|
||||
import sys
|
||||
|
||||
import conftest
|
||||
|
||||
if "nsh.const" not in sys.modules:
|
||||
conftest._load_module("nsh.const", conftest._SHIELD_DIR / "const.py")
|
||||
return sys.modules["nsh.const"]
|
||||
|
||||
|
||||
def _strings(name: str) -> dict:
|
||||
return json.loads((_SHIELD / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestThePublishedTarget:
|
||||
"""What HA is told the action accepts."""
|
||||
|
||||
def test_the_target_is_the_shields_media_player_entity(self, const):
|
||||
assert const.ADB_COMMAND_SERVICE_TARGET == {
|
||||
"entity": [{"domain": const.ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
assert const.ADB_COMMAND_TARGET_DOMAIN == "media_player"
|
||||
|
||||
def test_the_description_carries_the_target_and_the_two_fields(self, const):
|
||||
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
|
||||
# the picker: without this key the UI offers no Shield at all
|
||||
assert description["target"] is const.ADB_COMMAND_SERVICE_TARGET
|
||||
assert set(description["fields"]) == {"command", "entity_id"}
|
||||
assert description["fields"]["command"]["required"] is True
|
||||
# a plain (non-required) field: the TARGET is what selects the Shield, and
|
||||
# an entity_id given anyway still has to point at a participant
|
||||
assert "required" not in description["fields"]["entity_id"]
|
||||
|
||||
def test_the_response_key_is_the_one_the_old_integration_returned(self, const):
|
||||
# the user's captures read `adb_response`; keeping the name means a template
|
||||
# written against `androidtv.adb_command` keeps working
|
||||
assert const.ADB_COMMAND_RESPONSE_KEY == "adb_response"
|
||||
|
||||
|
||||
class TestTheCopyMatchesBothTranslationFiles:
|
||||
"""The description's prose is duplicated in the translations — so it cannot drift.
|
||||
|
||||
``strings.json``/``translations/en.json`` are where HA's translations live and
|
||||
the description is what the frontend receives; the two copies must stay
|
||||
identical, and every raised translation_key needs copy in BOTH files or the user
|
||||
sees a bare key (t29's lesson, applied to t14's two new keys).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
|
||||
def test_the_service_copy_matches_the_description(self, const, name):
|
||||
service = _strings(name)["services"]["adb_command"]
|
||||
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
|
||||
assert service["name"] == description["name"]
|
||||
assert service["description"] == description["description"]
|
||||
for field, copy in description["fields"].items():
|
||||
assert service["fields"][field]["name"] == copy["name"]
|
||||
assert service["fields"][field]["description"] == copy["description"]
|
||||
|
||||
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
|
||||
def test_the_new_exception_keys_have_copy(self, const, name):
|
||||
exceptions = _strings(name)["exceptions"]
|
||||
|
||||
for key in ("no_target", "ambiguous_target", "unsupported_target"):
|
||||
assert exceptions[key]["title"], (name, key)
|
||||
assert exceptions[key]["message"], (name, key)
|
||||
|
||||
# the placeholders the handler fills in are the ones the copy names
|
||||
assert "{entity_id}" in exceptions["ambiguous_target"]["message"]
|
||||
assert "{entity_id}" in exceptions["unsupported_target"]["message"]
|
||||
assert "{service}" in exceptions["no_target"]["message"]
|
||||
|
||||
def test_both_files_are_identical(self):
|
||||
# they are two copies of the same English copy — a divergence would mean one
|
||||
# of them is dead weight
|
||||
assert (_SHIELD / "strings.json").read_text(
|
||||
encoding="utf-8"
|
||||
) == (_SHIELD / "translations" / "en.json").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_entity_field_copy_names_the_picker(self):
|
||||
# the field the YAML caller passes has to say what to pass, now that the
|
||||
# picker is the primary way in
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
copy = _strings(name)["services"]["adb_command"]["fields"]["entity_id"]
|
||||
assert "media_player" in copy["description"]
|
||||
assert "device_id" in copy["description"]
|
||||
@@ -0,0 +1,646 @@
|
||||
"""t23/O1 — with ADB switched off, NO path reaches the device.
|
||||
|
||||
The t22 review measured the harm: with a poller injected into an entry whose own
|
||||
option said ``adb_enabled: False``, the installed-apps sweep sent **4** real
|
||||
shells and a label probe sent **1** more. Both gated on the POLLER's absence,
|
||||
which is a property of the setup code (``_build_poller_factory`` returns None when
|
||||
ADB is off) — exactly the indirect guarantee the t21 repair had already proven
|
||||
insufficient for the intent scan.
|
||||
|
||||
This file drives EVERY path in the connection manager that can touch the device
|
||||
and asserts on the TRAFFIC (shells, polls, commands, connects), because the
|
||||
traffic is the harm — a correct-looking status string is not the point. The
|
||||
device side is a scripted double that would answer everything: no real Shield is
|
||||
involved, so what is proven is that nothing was sent, not what firmware would do.
|
||||
|
||||
WHAT "NOTHING IS CACHED" MEANS EXACTLY (t24/O4): the ONE write a disabled path can
|
||||
still make is a retry DEBT — ``_app_label_failed.add(package)`` in ``_dump_label``
|
||||
— and a debt is not an answer: the label cache (``_app_labels``) stays untouched,
|
||||
``app_label`` keeps returning None, and the debt is settled the moment the probe
|
||||
finally runs with ADB back on. Asserted in
|
||||
``test_the_only_write_while_off_is_a_retry_debt_not_an_answer``.
|
||||
|
||||
WHAT THIS FILE DOES NOT GATE (t25/O1): the paths where "ADB off must mean no
|
||||
traffic" is deliberately NOT enforced, each listed in :data:`CARVE_OUTS` with its
|
||||
mitigation and with the test that asserts that mitigation — the Pair ADB button's
|
||||
press and ``ShieldConnectionManager.test_connect``. Naming them is the point: an
|
||||
audit that silently excludes a path is not an audit, and a carve-out whose
|
||||
assertion does not exist is prose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from nsh.connection import (
|
||||
FIELD_AWAKE,
|
||||
FIELD_FOREGROUND,
|
||||
FIELD_RUNNING_APPS,
|
||||
FIELD_SESSIONS,
|
||||
ShieldConnectionManager,
|
||||
)
|
||||
from nsh.const import CONF_ADB_ENABLED, CONF_LOGCAT_EVENTS
|
||||
from nsh.ha_adb_common import (
|
||||
INTENT_SCAN_ADB_DISABLED,
|
||||
INTENT_SCAN_ADB_UNAVAILABLE,
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
)
|
||||
|
||||
PKG = "com.netflix.ninja"
|
||||
|
||||
#: A minimal answer that IS a resolver section, so a scan that were allowed to run
|
||||
#: would count as a real answer (nothing here fails for a second reason).
|
||||
RESOLVER_SECTION = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Schemes:\n"
|
||||
" https:\n"
|
||||
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
|
||||
' Action: "android.intent.action.VIEW"\n'
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
|
||||
|
||||
class WillingPoller:
|
||||
"""A poller that answers EVERY call and records every one (t23/O1).
|
||||
|
||||
Nothing here refuses: if a path is still gated by the wrong fact, this double
|
||||
will happily talk to "the device" and the shell/command count proves it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.connected = True
|
||||
self.awake = True
|
||||
self.foreground = "app.a"
|
||||
self.audio_dump = " package=app.a\n"
|
||||
self.shell_calls: list[str] = []
|
||||
self.poll_calls: list[str] = []
|
||||
self.media_calls: list[str] = []
|
||||
self.digit_calls: list[str] = []
|
||||
self.foreground_sends: list[str] = []
|
||||
self.enter_calls = 0
|
||||
self.connect_calls = 0
|
||||
self.disconnect_calls = 0
|
||||
self.sessions = [SimpleNamespace(package="app.a", playing=True)]
|
||||
|
||||
# ── the transport ──
|
||||
async def connect(self) -> bool:
|
||||
self.connect_calls += 1
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.disconnect_calls += 1
|
||||
self.connected = False
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return self.connected
|
||||
|
||||
# ── the heartbeat ──
|
||||
async def is_awake(self) -> bool | None:
|
||||
self.poll_calls.append("is_awake")
|
||||
return self.awake
|
||||
|
||||
async def poll_media_sessions(self, foreground_package=None, head_lines=200):
|
||||
self.poll_calls.append("poll_media_sessions")
|
||||
return self.sessions[0] if self.sessions else None
|
||||
|
||||
@property
|
||||
def all_sessions(self):
|
||||
return self.sessions
|
||||
|
||||
@property
|
||||
def last_dump(self):
|
||||
return self.audio_dump
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
self.poll_calls.append("poll_running_apps")
|
||||
return [{"package": "app.a", "name": "App A", "icon": None}]
|
||||
|
||||
async def poll_foreground(self) -> str | None:
|
||||
self.poll_calls.append("poll_foreground")
|
||||
return self.foreground
|
||||
|
||||
# ── anything a shell can reach ──
|
||||
async def shell(self, command: str):
|
||||
self.shell_calls.append(command)
|
||||
if "Activity Resolver Table" in command:
|
||||
return RESOLVER_SECTION
|
||||
if "pm list packages" in command:
|
||||
return f"package:{PKG}\n"
|
||||
if "resolve-activity" in command:
|
||||
return f"{PKG}/.MainActivity\n"
|
||||
if "pm dump" in command:
|
||||
return 'Application Label: "Netflix"\n'
|
||||
return "ok\n"
|
||||
|
||||
async def send_media_command(self, command: str) -> bool:
|
||||
self.media_calls.append(command)
|
||||
return True
|
||||
|
||||
async def bring_to_foreground(self, package: str) -> bool:
|
||||
self.foreground_sends.append(package)
|
||||
return True
|
||||
|
||||
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||
self.digit_calls.append(digit)
|
||||
return True
|
||||
|
||||
async def send_enter_keyevent(self) -> bool:
|
||||
self.enter_calls += 1
|
||||
return True
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {"host": "shield.invalid", "port": 5555, "key_path": "/tmp/does-not-exist"}
|
||||
|
||||
@property
|
||||
def traffic(self) -> list[str]:
|
||||
"""EVERY device-touching call, in one list — the harm in one place (t23)."""
|
||||
return [
|
||||
*(f"shell:{call}" for call in self.shell_calls),
|
||||
*(f"poll:{call}" for call in self.poll_calls),
|
||||
*(f"media:{call}" for call in self.media_calls),
|
||||
*(f"digit:{digit}" for digit in self.digit_calls),
|
||||
*(f"foreground:{pkg}" for pkg in self.foreground_sends),
|
||||
*(["enter"] * self.enter_calls),
|
||||
*(["connect"] * self.connect_calls),
|
||||
]
|
||||
|
||||
|
||||
def _entry(*, adb_enabled: bool, **options):
|
||||
"""The entry's OWN options decide — this is the fact every path must ask."""
|
||||
return SimpleNamespace(
|
||||
options={
|
||||
CONF_ADB_ENABLED: adb_enabled,
|
||||
# ON, so the logcat path is skipped for the OPTION's reason (ADB off)
|
||||
# rather than for its own (a stream nobody asked for).
|
||||
CONF_LOGCAT_EVENTS: True,
|
||||
**options,
|
||||
},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
|
||||
def _manager(poller, *, adb_enabled: bool) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
|
||||
)
|
||||
|
||||
|
||||
#: THE AUDIT (t23/O1), as data: every path in the manager that can reach the
|
||||
#: device, with the call that drives it. Each is run against a manager whose
|
||||
#: option says ADB is off, and the poller's traffic must stay EMPTY.
|
||||
AUDITED_PATHS = [
|
||||
("intent scan", lambda m: m.async_scan_intents(PKG)),
|
||||
("intent scan (forced)", lambda m: m.async_scan_intents(PKG, force=True)),
|
||||
("installed-apps refresh", lambda m: m.async_refresh_installed_apps(force=True)),
|
||||
("installed-apps refresh (non-forced)", lambda m: m.async_refresh_installed_apps()),
|
||||
("app-label lookup", lambda m: _sync_label(m)),
|
||||
("app-label drain (watchdog side)", lambda m: _drain(m)),
|
||||
("adb text entry", lambda m: m.send_adb_input_text("hello")),
|
||||
("media key send", lambda m: m.send_media_command("play")),
|
||||
("bring to foreground", lambda m: m.bring_to_foreground(PKG)),
|
||||
("digit key send", lambda m: m.send_digit_key("5")),
|
||||
("enter key send", lambda m: m.send_enter_key()),
|
||||
("watchdog cycle", lambda m: m._watchdog_cycle(5.0)),
|
||||
("heartbeat refresh", lambda m: m._refresh()),
|
||||
("logcat ensure", lambda m: _logcat(m)),
|
||||
("logcat open", lambda m: _open_logcat(m)),
|
||||
("logcat ladder", lambda m: m._run_logcat_stream()),
|
||||
("watchdog start", lambda m: _start_watchdog(m)),
|
||||
]
|
||||
|
||||
|
||||
#: NAMED CARVE-OUTS (t25/O1) — the device paths this audit does NOT gate, each with
|
||||
#: what it does, why that is safe, and the assertion that proves the mitigation. A
|
||||
#: carve-out is a claim like every other claim here, so
|
||||
#: ``test_every_carve_out_names_a_real_assertion`` checks that the named assertion
|
||||
#: actually exists in the tree: a carve-out cannot point at a phantom.
|
||||
CARVE_OUTS = (
|
||||
{
|
||||
"name": "Pair ADB button press (button.py::NvidiaShieldPairAdbButton)",
|
||||
"what_it_does": (
|
||||
"builds its OWN AdbMediaSessionPoller and connects it, and never reads "
|
||||
"the entry's ADB option — MEASURED ctor/connect/disconnect 1/1/1 with "
|
||||
"the option OFF, identical to option-ON (driven, not quoted)"
|
||||
),
|
||||
"why_safe": (
|
||||
"there is no production route to the press with ADB off: "
|
||||
"button.async_setup_entry adds that entity ONLY when the option is on, "
|
||||
"so a disabled entry has no button to press; every options save runs "
|
||||
"through NvidiaShieldOptionsFlow(OptionsFlowWithReload), which reloads "
|
||||
"the entry and re-runs that setup, so turning ADB off takes the button "
|
||||
"away; and a press is user-initiated — the same class as the "
|
||||
"test_connect pairing probe below"
|
||||
),
|
||||
"asserted_by": (
|
||||
"tests/test_intents_registration.py",
|
||||
"test_no_pair_adb_button_while_adb_is_off",
|
||||
),
|
||||
"measured_by": (
|
||||
"tests/test_intents_registration.py",
|
||||
"test_the_press_does_not_consult_the_option_and_that_is_pinned",
|
||||
),
|
||||
"not_the_same_as": (
|
||||
"the INTENT button, which CAN exist for an ADB-off entry and is NOT a "
|
||||
"carve-out: it sends through run_registered_intent, which resolves the "
|
||||
"poller through the gated adb_poller accessor (media_player.py:2317) and "
|
||||
"refuses with `intents_need_adb` — zero traffic"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ShieldConnectionManager.test_connect (the config-flow pairing probe)",
|
||||
"what_it_does": (
|
||||
"opens a throwaway AdbMediaSessionPoller and connects, before any entry "
|
||||
"exists, consulting no option at all"
|
||||
),
|
||||
"why_safe": (
|
||||
"the setup menu offers that step only while the setup's own ADB option "
|
||||
"is on (the menu row is built from _user_data[CONF_ADB_ENABLED]), and "
|
||||
"the probe IS the user's explicit pairing action"
|
||||
),
|
||||
"asserted_by": (
|
||||
"tests/test_adb_off_gates_every_device_path.py",
|
||||
"test_the_pairing_probe_is_offered_only_while_the_option_is_on",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_await(value):
|
||||
"""Drive an audited path: some are coroutines, some are plain calls (t23)."""
|
||||
if asyncio.iscoroutine(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
|
||||
def _sync_label(manager):
|
||||
return manager.app_label(PKG)
|
||||
|
||||
|
||||
async def _settle_label_tasks(manager):
|
||||
"""Await any label fetch the drain started, so its shell WOULD have happened."""
|
||||
for task in list(manager._tasks):
|
||||
if task.get_name().startswith("app_label"):
|
||||
with contextlib.suppress(Exception):
|
||||
await task
|
||||
|
||||
|
||||
async def _drain(manager):
|
||||
"""The render path queues a label fetch, then the watchdog drains the queue."""
|
||||
manager.app_label(PKG)
|
||||
manager._drain_app_label_requests()
|
||||
await _settle_label_tasks(manager)
|
||||
|
||||
|
||||
def _logcat(manager):
|
||||
manager._ensure_logcat_stream()
|
||||
|
||||
|
||||
def _open_logcat(manager):
|
||||
"""The logcat OPEN path: it raises while disabled, so swallow it here.
|
||||
|
||||
``test_the_logcat_open_refuses_with_the_disabled_message`` asserts the message;
|
||||
this driver only has to prove the raise cost no traffic.
|
||||
"""
|
||||
with contextlib.suppress(RuntimeError):
|
||||
manager._open_logcat_stream()
|
||||
|
||||
|
||||
async def _start_watchdog(manager):
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0.01) # would be one cycle if the loop had started
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestEveryAuditedPathSendsNothing:
|
||||
"""ADB off + a poller that WOULD answer = zero traffic on every path."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "drive"), AUDITED_PATHS, ids=[name for name, _ in AUDITED_PATHS]
|
||||
)
|
||||
async def test_the_path_does_not_touch_the_device(self, name, drive):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await _maybe_await(drive(manager))
|
||||
await asyncio.sleep(0) # let any task the path started really run
|
||||
|
||||
assert poller.traffic == [], f"{name} reached the device: {poller.traffic}"
|
||||
|
||||
async def test_a_full_cycle_of_every_path_still_sends_nothing(self):
|
||||
"""One manager, every path in sequence — the whole cycle, not one call."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
# 1. the intent scan (t21's repair, asked again one layer deeper)
|
||||
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
|
||||
assert (
|
||||
await manager.async_scan_intents(PKG, force=True)
|
||||
).status == INTENT_SCAN_ADB_DISABLED
|
||||
|
||||
# 2. the installed-apps sweep — the 4-shell path the review measured
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps() == []
|
||||
|
||||
# 3. the app-label probe — the 1-shell path, both entry points
|
||||
assert manager.app_label(PKG) is None
|
||||
manager._drain_app_label_requests()
|
||||
await _settle_label_tasks(manager)
|
||||
|
||||
# 4. text entry and every key/command delegation
|
||||
assert await manager.send_adb_input_text("hello") is False
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert await manager.bring_to_foreground(PKG) is False
|
||||
assert await manager.send_digit_key("5") is False
|
||||
assert await manager.send_enter_key() is False
|
||||
|
||||
# 5. the heartbeat, the watchdog and the logcat stream
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await _start_watchdog(manager)
|
||||
manager._ensure_logcat_stream()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# 6. the public accessor the OUT-OF-FILE consumers (adb_command service,
|
||||
# screen capture, intent shell) read — it must refuse too
|
||||
assert manager.adb_poller is None
|
||||
|
||||
assert poller.traffic == [], poller.traffic
|
||||
|
||||
async def test_nothing_is_cached_when_the_cycle_is_refused(self):
|
||||
"""No cache write either: an unpermitted scan is not an answer."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager.async_scan_intents(PKG)
|
||||
await manager.async_refresh_installed_apps(force=True)
|
||||
manager.app_label(PKG)
|
||||
manager._drain_app_label_requests()
|
||||
await manager._refresh()
|
||||
|
||||
assert manager.intent_scan_state(PKG) is None, "no intent answer"
|
||||
assert manager.installed_apps() == [], "no app table"
|
||||
assert manager.installed_apps_stale is True, "nothing was stamped fresh"
|
||||
assert manager.app_label_requests == set(), "nothing queued"
|
||||
assert manager.app_label_inflight == set(), "nothing in flight"
|
||||
assert manager.store.get(FIELD_AWAKE) is None, "no wake reading"
|
||||
assert manager.store.get(FIELD_SESSIONS, []) == []
|
||||
assert manager.store.get(FIELD_RUNNING_APPS, []) == []
|
||||
assert manager.store.get(FIELD_FOREGROUND) is None
|
||||
assert manager.awake is None
|
||||
|
||||
|
||||
class TestTheWatchdogNeverConnects:
|
||||
"""A TCP connect is device traffic too — the loop must not even start."""
|
||||
|
||||
async def test_the_watchdog_does_not_build_a_poller_or_connect(self):
|
||||
ours = WillingPoller()
|
||||
built = []
|
||||
|
||||
def factory():
|
||||
built.append(1)
|
||||
return ours
|
||||
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=_entry(adb_enabled=False),
|
||||
poller_factory=factory,
|
||||
)
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0.02)
|
||||
await manager.async_stop()
|
||||
|
||||
assert built == [], "the factory was never asked for a poller"
|
||||
assert ours.connect_calls == 0, "and nothing was connected"
|
||||
assert ours.traffic == []
|
||||
|
||||
async def test_a_direct_cycle_does_not_connect_either(self):
|
||||
poller = WillingPoller()
|
||||
poller.connected = False
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert poller.connect_calls == 0
|
||||
assert poller.traffic == []
|
||||
|
||||
|
||||
class TestTheOptionIsACheckNotALatch:
|
||||
"""Turning ADB back on resumes normal work — no remembered decision."""
|
||||
|
||||
async def test_the_same_manager_resumes_when_the_option_flips(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.app_label(PKG) is None
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert poller.traffic == [], "nothing while it is off"
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True # the user turns ADB on
|
||||
|
||||
rows = await manager.async_refresh_installed_apps(force=True)
|
||||
assert rows, "the sweep really ran once ADB is on"
|
||||
assert manager.app_label("com.other.app") is None # queued, not sent yet
|
||||
assert manager.app_label_requests == {"com.other.app"}
|
||||
assert await manager.send_media_command("play") is True
|
||||
assert await manager.send_digit_key("5") is True
|
||||
assert manager.adb_poller is poller
|
||||
assert poller.shell_calls, "the device was reached again"
|
||||
assert poller.media_calls == ["play"]
|
||||
assert poller.digit_calls == ["5"]
|
||||
|
||||
async def test_the_intent_scan_resumes_too(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True
|
||||
|
||||
result = await manager.async_scan_intents(PKG)
|
||||
assert result.status in (INTENT_SCAN_FRESH, INTENT_SCAN_FRESH_EMPTY)
|
||||
assert result.answered is True
|
||||
assert manager.intent_scan_state(PKG) is not None, "the answer is cached now"
|
||||
|
||||
|
||||
class TestTheOtherStatusesAreUntouched:
|
||||
"""ADB ON with no poller is 'unavailable', never 'disabled' (t21's split)."""
|
||||
|
||||
async def test_no_poller_reads_as_unavailable(self):
|
||||
manager = _manager(None, adb_enabled=True)
|
||||
|
||||
assert (await manager.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_UNAVAILABLE
|
||||
)
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps_status == "unknown"
|
||||
assert await manager.send_media_command("play") is False
|
||||
|
||||
async def test_adb_off_and_no_poller_are_told_apart(self):
|
||||
disabled = _manager(WillingPoller(), adb_enabled=False)
|
||||
unbuilt = _manager(None, adb_enabled=True)
|
||||
|
||||
assert (await disabled.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_DISABLED
|
||||
)
|
||||
assert (await unbuilt.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_UNAVAILABLE
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _source(relative_path: str) -> str:
|
||||
return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestTheNamedCarveOuts:
|
||||
"""Every exclusion is a NAMED claim with an assertion behind it (t25/O1)."""
|
||||
|
||||
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
|
||||
def test_every_carve_out_names_a_real_assertion(self, carve_out):
|
||||
"""No phantom carve-outs: the named assertion must exist in the tree."""
|
||||
for key in ("asserted_by", "measured_by"):
|
||||
pointer = carve_out.get(key)
|
||||
if pointer is None:
|
||||
continue
|
||||
relative, name = pointer
|
||||
assert f"def {name}(" in _source(relative), (
|
||||
f"{carve_out['name']} claims {key}={relative}::{name}, which does "
|
||||
"not exist"
|
||||
)
|
||||
assert carve_out["what_it_does"], "a carve-out states what it does"
|
||||
assert carve_out["why_safe"], "a carve-out states its mitigation"
|
||||
|
||||
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
|
||||
def test_every_carve_out_is_outside_the_audited_table(self, carve_out):
|
||||
"""A carve-out is an EXCLUSION — it must not also claim to be driven."""
|
||||
driven = {name for name, _ in AUDITED_PATHS}
|
||||
assert all(
|
||||
carve_out["name"] not in driven_path for driven_path in driven
|
||||
), carve_out["name"]
|
||||
|
||||
def test_the_pairing_probe_is_offered_only_while_the_option_is_on(self):
|
||||
"""`test_connect` has no entry to ask, so the SETUP MENU is its gate."""
|
||||
config_flow = _source("custom_components/nvidia_shield/config_flow.py")
|
||||
|
||||
menu_guard = (
|
||||
"if self._user_data.get(CONF_ADB_ENABLED) and not getattr(\n"
|
||||
' self, "_paired_adb", False\n'
|
||||
" ):\n"
|
||||
' options.append("adb_pair")'
|
||||
)
|
||||
assert menu_guard in config_flow, (
|
||||
"the adb_pair row (the only route to the pairing probe) must stay "
|
||||
"behind the setup's ADB option"
|
||||
)
|
||||
|
||||
|
||||
class TestTheLogcatPathsTheAuditNames:
|
||||
"""The audit text names these two paths — so they are DRIVEN here (t25/O2)."""
|
||||
|
||||
async def test_the_logcat_open_refuses_with_the_disabled_message(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="ADB is switched off"):
|
||||
manager._open_logcat_stream()
|
||||
|
||||
assert poller.traffic == [], "the refusal cost no traffic"
|
||||
|
||||
async def test_the_logcat_ladder_returns_without_counting_a_failure(self):
|
||||
"""A local option must not look like a device failure (t23, pinned t25)."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager._run_logcat_stream() # returns at once: nothing to consume
|
||||
|
||||
assert manager._logcat_failures == [], "no failure was recorded"
|
||||
assert manager._logcat_disabled is False, "and the stream was not disabled"
|
||||
assert poller.traffic == []
|
||||
|
||||
async def test_five_real_logcat_failures_still_auto_disable(self):
|
||||
"""THE ANTI-OVERCORRECTION PIN: the ladder itself still works when ADB is on."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=True)
|
||||
|
||||
async def failing_pass():
|
||||
raise RuntimeError("stream ended (device closed)")
|
||||
|
||||
async def no_sleep(_seconds):
|
||||
return None
|
||||
|
||||
manager._logcat_pass = failing_pass
|
||||
manager._logcat_ladder_sleep = no_sleep
|
||||
|
||||
await manager._run_logcat_stream()
|
||||
|
||||
assert manager._logcat_disabled is True, "five real failures still trip it"
|
||||
assert manager._entry_options_logcat() is True, "the option itself is unchanged"
|
||||
|
||||
|
||||
class TestTheOneWriteWhileOffIsDebt:
|
||||
"""t25/O4: "nothing is cached" is exact because the one write is a DEBT."""
|
||||
|
||||
async def test_the_only_write_while_off_is_a_retry_debt_not_an_answer(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
# the one disabled call that reaches the probe directly (the watchdog drain
|
||||
# and the public lookup are gated above it, so this is the route the review
|
||||
# measured the write on)
|
||||
await manager._fetch_app_label(PKG)
|
||||
|
||||
assert manager._app_labels == {}, "no label was CACHED — that is the claim"
|
||||
assert manager._app_label_failed == {PKG}, "only the retry DEBT was recorded"
|
||||
assert manager.app_label(PKG) is None, "and the lookup still answers nothing"
|
||||
assert manager.app_label_inflight == set()
|
||||
assert poller.traffic == [], "the debt cost no traffic"
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True # ADB is turned back on
|
||||
await manager._fetch_app_label(PKG) # the owed retry finally runs
|
||||
|
||||
assert manager._app_labels.get(PKG) is not None, "the probe ran and cached"
|
||||
assert manager._app_label_failed == set(), "and the debt was settled"
|
||||
|
||||
|
||||
class TestAMissingOptionFailsClosed:
|
||||
"""Where the `entry=None`-means-ADB-off behaviour is asserted (t25)."""
|
||||
|
||||
async def test_an_entry_with_no_adb_option_is_disabled(self):
|
||||
"""No option recorded = OFF, so the harness change matched production."""
|
||||
poller = WillingPoller()
|
||||
entry = SimpleNamespace(options={}, data={}, title="Shield", entry_id="e1")
|
||||
manager = ShieldConnectionManager(hass=None, entry=entry, poller=poller)
|
||||
|
||||
assert manager._adb_disabled() is True
|
||||
assert manager._adb_active() is False
|
||||
assert manager.adb_poller is None
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps() == []
|
||||
assert poller.traffic == []
|
||||
|
||||
async def test_a_manager_with_no_entry_at_all_is_disabled_too(self):
|
||||
"""The old harness shape (`entry=None`) is the same fact."""
|
||||
poller = WillingPoller()
|
||||
manager = ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||
|
||||
assert manager._adb_disabled() is True
|
||||
assert await manager.send_digit_key("5") is False
|
||||
assert poller.traffic == []
|
||||
|
||||
def test_the_production_default_is_read_the_same_way(self):
|
||||
"""`__init__.py` gates the poller on the same missing-option default."""
|
||||
entry_source = _source("custom_components/nvidia_shield/__init__.py")
|
||||
|
||||
assert "if not entry.options.get(CONF_ADB_ENABLED, False):" in entry_source, (
|
||||
"production treats a missing option as OFF, exactly like the manager"
|
||||
)
|
||||
+1073
-78
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ from types import SimpleNamespace
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
SCRIPT_VAR_APP_NAME,
|
||||
@@ -317,7 +318,8 @@ def test_the_remote_entry_options_are_the_media_player_options(modules):
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
== entity._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
)
|
||||
assert make_manager().entry is None # the harness's manager starts unbound
|
||||
# the harness's manager carries a realistic entry whose option says ADB is on
|
||||
assert make_manager().entry.options[CONF_ADB_ENABLED] is True
|
||||
|
||||
|
||||
WATCH_SCRIPT = "script.shield_remote"
|
||||
|
||||
@@ -211,6 +211,7 @@ def _install_stubs() -> None:
|
||||
SENSOR = "sensor"
|
||||
REMOTE = "remote"
|
||||
BUTTON = "button"
|
||||
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
|
||||
ha_const.Platform = _Platform
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ receives and the exact ``user_input`` a submission produces.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
@@ -255,6 +257,24 @@ async def open_scripts(hass, entry):
|
||||
return manager, scripts
|
||||
|
||||
|
||||
async def open_apps_menu(hass, entry):
|
||||
"""Open the Apps editor (t64: a MENU of row actions) through the real menu."""
|
||||
manager, menu = await open_options(hass, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
assert apps["type"] == "menu", apps
|
||||
return manager, apps
|
||||
|
||||
|
||||
async def open_apps_text(hass, entry):
|
||||
"""Open the Apps editor's TEXT step — the whole-list multiline field (t64)."""
|
||||
manager, apps = await open_apps_menu(hass, entry)
|
||||
text = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_text"}
|
||||
)
|
||||
assert text["type"] == "form", text
|
||||
return manager, text
|
||||
|
||||
|
||||
class TestRealMenuFlow:
|
||||
"""The options page is a real menu with real sub-screens."""
|
||||
|
||||
@@ -268,6 +288,7 @@ class TestRealMenuFlow:
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"intents",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
@@ -286,7 +307,12 @@ class TestRealMenuFlow:
|
||||
opened = await _manager.async_configure(
|
||||
fresh["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] == "form", row
|
||||
# a sub-screen is a FORM — except the two EDITORS, which are MENUs of
|
||||
# row actions: HA has no table selector, so a table is edited one row
|
||||
# per step (t64 Apps, t17 Intents), and every row they offer has to be
|
||||
# dispatchable too
|
||||
expected = "menu" if row in ("apps", "intents") else "form"
|
||||
assert opened["type"] == expected, (row, opened)
|
||||
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
||||
# step_id must be the menu row id (the method name)
|
||||
assert opened["step_id"] == row
|
||||
@@ -305,13 +331,171 @@ class TestRealMenuFlow:
|
||||
)
|
||||
|
||||
|
||||
class TestRealIntentsScreen:
|
||||
"""t17: the Intents editor driven by the REAL FlowManager.
|
||||
|
||||
This is the class of bug this file exists for: HA dispatches a menu row to
|
||||
``async_step_<row id>`` and a submitted form to ``async_step_<its step_id>``,
|
||||
so a step id that does not match its method is an UnknownStep — invisible to a
|
||||
stub harness that calls the methods directly.
|
||||
"""
|
||||
|
||||
REGISTRATION = {
|
||||
"intent_name": "Netflix: open a title",
|
||||
"intent_package": "com.netflix.ninja",
|
||||
"intent_activity": "com.netflix.ninja/.MainActivity",
|
||||
"intent_action": "android.intent.action.VIEW",
|
||||
"intent_categories": "android.intent.category.DEFAULT",
|
||||
"intent_data": "https://www.netflix.com/watch/{id}",
|
||||
"intent_fields": "id:string:required",
|
||||
}
|
||||
|
||||
async def _open_intents(self, ha, entry):
|
||||
manager, menu = await open_options(ha, entry)
|
||||
intents = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "intents"}
|
||||
)
|
||||
assert intents["type"] == "menu", intents
|
||||
return manager, intents
|
||||
|
||||
async def test_the_screen_is_a_menu_of_row_actions(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, intents = await self._open_intents(ha, entry)
|
||||
|
||||
assert intents["step_id"] == "intents"
|
||||
# nothing registered yet, so there is nothing to remove
|
||||
assert set(intents["menu_options"]) == {
|
||||
"intents_edit",
|
||||
"intents_scan",
|
||||
"menu_again",
|
||||
}
|
||||
assert field_names(intents["data_schema"]) == ["next_step_id"]
|
||||
|
||||
async def test_the_selector_carries_the_add_new_sentinel(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, intents = await self._open_intents(ha, entry)
|
||||
|
||||
edit = await manager.async_configure(
|
||||
intents["flow_id"], {"next_step_id": "intents_edit"}
|
||||
)
|
||||
assert edit["type"] == "form" and edit["step_id"] == "intents_edit"
|
||||
|
||||
choice = next(f for f in field_list(edit["data_schema"]) if f["name"] == "intent_choice")
|
||||
options = choice["selector"]["select"]["options"]
|
||||
assert [option["value"] for option in options] == ["__new__"], (
|
||||
"the Add-new sentinel is an OPTION the user can see"
|
||||
)
|
||||
|
||||
async def test_a_registration_round_trips_through_the_real_flow(self, ha):
|
||||
"""ADD, SAVE, REOPEN: the registration is in the options afterwards."""
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, intents = await self._open_intents(ha, entry)
|
||||
edit = await manager.async_configure(
|
||||
intents["flow_id"], {"next_step_id": "intents_edit"}
|
||||
)
|
||||
form = await manager.async_configure(
|
||||
edit["flow_id"], {"intent_choice": "__new__"}
|
||||
)
|
||||
assert form["type"] == "form" and form["step_id"] == "intents_form"
|
||||
|
||||
saved = await manager.async_configure(form["flow_id"], dict(self.REGISTRATION))
|
||||
assert saved["type"] == "menu" and saved["step_id"] == "intents", (
|
||||
"the row is stored in the flow's options, nothing committed yet"
|
||||
)
|
||||
|
||||
back = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["type"] == "create_entry"
|
||||
stored = created["data"]["registered_intents"]
|
||||
assert len(stored) == 1
|
||||
assert stored[0]["name"] == "Netflix: open a title"
|
||||
assert stored[0]["activity"] == "com.netflix.ninja/.MainActivity"
|
||||
assert stored[0]["fields"] == [{"key": "id", "type": "string", "required": True}]
|
||||
# JSON, because that is how the options are persisted
|
||||
json.dumps(created["data"])
|
||||
|
||||
async def test_a_bad_activity_is_refused_by_the_real_flow(self, ha):
|
||||
"""The validator runs where the user is: the form comes back with a note."""
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, intents = await self._open_intents(ha, entry)
|
||||
edit = await manager.async_configure(
|
||||
intents["flow_id"], {"next_step_id": "intents_edit"}
|
||||
)
|
||||
form = await manager.async_configure(
|
||||
edit["flow_id"], {"intent_choice": "__new__"}
|
||||
)
|
||||
|
||||
rejected = await manager.async_configure(
|
||||
form["flow_id"], {**self.REGISTRATION, "intent_activity": "no-slash"}
|
||||
)
|
||||
|
||||
assert rejected["type"] == "form" and rejected["step_id"] == "intents_form"
|
||||
assert rejected["errors"] == {"base": "invalid_intent_row"}
|
||||
assert "not a component" in (
|
||||
rejected.get("description_placeholders") or {}
|
||||
).get("intent_note", "")
|
||||
|
||||
async def test_a_registered_intent_can_be_removed_and_confirmed(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
# register one through the real flow first
|
||||
intents = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "intents"}
|
||||
)
|
||||
edit = await manager.async_configure(
|
||||
intents["flow_id"], {"next_step_id": "intents_edit"}
|
||||
)
|
||||
form = await manager.async_configure(
|
||||
edit["flow_id"], {"intent_choice": "__new__"}
|
||||
)
|
||||
saved = await manager.async_configure(form["flow_id"], dict(self.REGISTRATION))
|
||||
|
||||
# ...then remove it, through the same menu
|
||||
again = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
back_in = await manager.async_configure(
|
||||
again["flow_id"], {"next_step_id": "intents"}
|
||||
)
|
||||
assert "intents_remove" in back_in["menu_options"], (
|
||||
"a registered intent offers removal"
|
||||
)
|
||||
remove = await manager.async_configure(
|
||||
back_in["flow_id"], {"next_step_id": "intents_remove"}
|
||||
)
|
||||
confirm = await manager.async_configure(
|
||||
remove["flow_id"], {"intent_choice": "netflix_open_a_title"}
|
||||
)
|
||||
assert confirm["type"] == "menu" and confirm["step_id"] == (
|
||||
"intents_remove_confirm"
|
||||
)
|
||||
|
||||
gone = await manager.async_configure(
|
||||
confirm["flow_id"], {"next_step_id": "intents_remove_yes"}
|
||||
)
|
||||
assert gone["step_id"] == "intents"
|
||||
assert set(gone["menu_options"]) == {"intents_edit", "intents_scan", "menu_again"}
|
||||
|
||||
finished = await manager.async_configure(
|
||||
gone["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
finished["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["registered_intents"] == []
|
||||
|
||||
|
||||
class TestRealAppsField:
|
||||
"""[object Object]: the field list must carry TEXT, never a dict."""
|
||||
|
||||
async def test_the_apps_suggestion_is_the_serialized_text(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
suggested_values = suggested(apps["data_schema"])
|
||||
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
||||
@@ -325,8 +509,7 @@ class TestRealAppsField:
|
||||
|
||||
async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
fields = field_list(apps["data_schema"])
|
||||
assert [f["name"] for f in fields] == ["app_name_map"]
|
||||
@@ -334,17 +517,22 @@ class TestRealAppsField:
|
||||
|
||||
async def test_a_table_row_round_trips_through_the_real_flow(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
saved = await manager.async_configure(
|
||||
apps["flow_id"],
|
||||
{"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"},
|
||||
)
|
||||
assert saved["type"] == "menu" # back at the menu, nothing committed yet
|
||||
assert saved["type"] == "menu" # back at the APPS editor, nothing committed
|
||||
assert saved["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
assert back["type"] == "menu" and back["step_id"] == "init"
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
@@ -506,13 +694,22 @@ class TestRealNoLostEdits:
|
||||
apps = await manager.async_configure(
|
||||
after_scripts["flow_id"], {"next_step_id": "apps"}
|
||||
)
|
||||
after_apps = await manager.async_configure(
|
||||
apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||
assert apps["type"] == "menu" and apps["step_id"] == "apps"
|
||||
text = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_text"}
|
||||
)
|
||||
assert after_apps["type"] == "menu"
|
||||
after_apps = await manager.async_configure(
|
||||
text["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||
)
|
||||
assert after_apps["type"] == "menu" and after_apps["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
assert back["type"] == "menu" and back["step_id"] == "init"
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "finish"}
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
data = created["data"]
|
||||
@@ -815,3 +1012,570 @@ class TestRealSetupMenu:
|
||||
assert menu["type"] == "menu"
|
||||
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||
assert "ADB" in note and "remote protocol" in note
|
||||
|
||||
|
||||
# ------------------------------------------- t64: the apps editor (real HA)
|
||||
#
|
||||
# Both live reports are about this screen: the map arrived as ONE LONG LINE with
|
||||
# rows that could not be added, and "Discover installed apps seems to load then do
|
||||
# nothing". The fixes are pinned here, over the REAL FlowManager, because that is
|
||||
# where the field list the browser renders and the exact ``user_input`` a
|
||||
# submission produces are decided. What is NOT provable here: how the field LOOKS.
|
||||
# There is no browser, so "multiline" is proven through the serialised selector
|
||||
# (``selector.text.multiline``) — the value the frontend actually reads.
|
||||
|
||||
|
||||
class _FakePairingClient:
|
||||
"""The async pairing surface the flows drive (t15) — a double, no socket.
|
||||
|
||||
Only what the flows call: generate/start/finish plus ``disconnect`` for the
|
||||
throwaway session. ``finish_error`` reproduces a wrong PIN.
|
||||
"""
|
||||
|
||||
def __init__(self, finish_error: Exception | None = None):
|
||||
self.finish_calls: list[str] = []
|
||||
self.disconnect_calls = 0
|
||||
self._finish_error = finish_error
|
||||
|
||||
async def async_generate_cert_if_missing(self) -> None:
|
||||
return None
|
||||
|
||||
async def async_start_pairing(self) -> None:
|
||||
return None
|
||||
|
||||
async def async_finish_pairing(self, code: str) -> None:
|
||||
self.finish_calls.append(code)
|
||||
if self._finish_error is not None:
|
||||
raise self._finish_error
|
||||
|
||||
def disconnect(self) -> None:
|
||||
self.disconnect_calls += 1
|
||||
|
||||
async def async_get_name_and_mac(self) -> tuple[str, str]:
|
||||
"""The setup flow asks for the device identity right after pairing (t15)."""
|
||||
return "Shield", "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
|
||||
class TestRealOptionsProtocolPairing:
|
||||
"""t15: switching the transport ON on a never-paired entry must PAIR.
|
||||
|
||||
The live report: the transport was switched on, NO PIN prompt appeared, and the
|
||||
remote then looked unpaired whether it was or not. Measured cause: the certificate
|
||||
from setup is reused by design (so a PAIRED entry correctly asks for nothing), and
|
||||
the library has no ``is_paired`` — which is why the marker is persisted and why an
|
||||
unpaired entry is routed into the pairing step instead of being let loose.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _connection_page(entry, **overrides):
|
||||
"""The options Connection page as the real frontend submits it (t60)."""
|
||||
page = {
|
||||
"host": entry.options.get("host", "10.0.0.9"),
|
||||
"adb_port": 5555,
|
||||
"adb_enabled": True,
|
||||
"adb_store_app_info": True,
|
||||
"protocol_enabled": True,
|
||||
"logcat_events": False,
|
||||
"adb_command_enabled": False,
|
||||
"screen_capture_enabled": False,
|
||||
"jellyfin_entity": "",
|
||||
"power_entity": "",
|
||||
}
|
||||
page.update(overrides)
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def _fake_pairing(monkeypatch, client):
|
||||
"""Answer the module-level pairing builder with a client double (t15)."""
|
||||
|
||||
async def _start(hass, host, client_name):
|
||||
return client, None
|
||||
|
||||
monkeypatch.setattr(cf, "_async_start_protocol_pairing", _start)
|
||||
|
||||
async def _open_connection(self, ha, entry):
|
||||
manager, menu = await open_options(ha, entry)
|
||||
page = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "connection"}
|
||||
)
|
||||
assert page["type"] == "form" and page["step_id"] == "connection"
|
||||
return manager, page
|
||||
|
||||
async def test_enabling_the_transport_on_an_unpaired_entry_pairs(self, ha, monkeypatch):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
|
||||
client = _FakePairingClient()
|
||||
self._fake_pairing(monkeypatch, client)
|
||||
manager, page = await self._open_connection(ha, entry)
|
||||
|
||||
result = await manager.async_configure(
|
||||
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
|
||||
)
|
||||
|
||||
assert result["type"] == "form", result
|
||||
assert result["step_id"] == "protocol_pair", (
|
||||
"an unpaired entry must not end up running an unauthenticated client"
|
||||
)
|
||||
assert result["data_schema"] is not None
|
||||
|
||||
async def test_the_pin_then_marks_the_entry_paired_and_keeps_it_enabled(
|
||||
self, ha, monkeypatch
|
||||
):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
|
||||
client = _FakePairingClient()
|
||||
self._fake_pairing(monkeypatch, client)
|
||||
manager, page = await self._open_connection(ha, entry)
|
||||
pair_form = await manager.async_configure(
|
||||
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
|
||||
)
|
||||
|
||||
after_code = await manager.async_configure(
|
||||
pair_form["flow_id"], {"pairing_code": "A1B2C3", "host": "10.0.0.9"}
|
||||
)
|
||||
|
||||
assert after_code["type"] == "menu", after_code
|
||||
assert after_code["step_id"] == "init"
|
||||
assert client.finish_calls == ["A1B2C3"]
|
||||
assert client.disconnect_calls == 1, "the throwaway session is closed"
|
||||
|
||||
saved = await manager.async_configure(
|
||||
after_code["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert saved["type"] == "create_entry"
|
||||
assert saved["data"]["protocol_paired"] is True
|
||||
assert saved["data"]["protocol_enabled"] is True
|
||||
|
||||
async def test_skipping_the_pairing_leaves_the_transport_off(self, ha, monkeypatch):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9", "protocol_enabled": False})
|
||||
self._fake_pairing(monkeypatch, _FakePairingClient())
|
||||
manager, page = await self._open_connection(ha, entry)
|
||||
pair_form = await manager.async_configure(
|
||||
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
|
||||
)
|
||||
|
||||
after_skip = await manager.async_configure(
|
||||
pair_form["flow_id"],
|
||||
{"pairing_code": "", "host": "10.0.0.9", "skip_pairing": True},
|
||||
)
|
||||
|
||||
assert after_skip["type"] == "menu", after_skip
|
||||
saved = await manager.async_configure(
|
||||
after_skip["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert saved["data"]["protocol_enabled"] is False, "stay off, never half-paired"
|
||||
assert saved["data"]["protocol_paired"] is False
|
||||
|
||||
async def test_a_paired_entry_is_not_asked_for_a_pin_again(self, ha, monkeypatch):
|
||||
"""(a) documented: the setup certificate is REUSED, so no new PIN."""
|
||||
entry = make_entry(
|
||||
ha,
|
||||
options={
|
||||
"host": "10.0.0.9",
|
||||
"protocol_enabled": False,
|
||||
"protocol_paired": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def _must_not_run(hass, host, client_name): # pragma: no cover
|
||||
raise AssertionError("a paired entry must not start a pairing session")
|
||||
|
||||
monkeypatch.setattr(cf, "_async_start_protocol_pairing", _must_not_run)
|
||||
manager, page = await self._open_connection(ha, entry)
|
||||
|
||||
result = await manager.async_configure(
|
||||
page["flow_id"], self._connection_page(entry, protocol_enabled=True)
|
||||
)
|
||||
|
||||
assert result["type"] == "menu", result
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
|
||||
class TestRealSetupProtocolPairing:
|
||||
"""t15: the SETUP pairing writes the marker the options flow reads.
|
||||
|
||||
Both halves of the pairing contract in one place: the flow that pairs records it,
|
||||
and the skip path records the opposite, so no entry can be enabled-but-unpaired
|
||||
after a reload — the state the old code could not tell apart from a working one.
|
||||
"""
|
||||
|
||||
async def _pair_through_setup(self, ha, monkeypatch, client, **form_overrides):
|
||||
"""Drive setup → protocol_pair → the PIN → the setup menu → create_entry."""
|
||||
|
||||
async def _begin(self):
|
||||
return client, None
|
||||
|
||||
monkeypatch.setattr(
|
||||
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _begin
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
first_form(adb_enabled=False, protocol_enabled=True, **form_overrides),
|
||||
)
|
||||
assert pairing["type"] == "form" and pairing["step_id"] == "protocol_pair"
|
||||
return manager, pairing
|
||||
|
||||
async def test_a_successful_setup_pairing_sets_the_marker(self, ha, monkeypatch):
|
||||
client = _FakePairingClient()
|
||||
manager, pairing = await self._pair_through_setup(ha, monkeypatch, client)
|
||||
|
||||
after_code = await manager.async_configure(
|
||||
pairing["flow_id"], {"pairing_code": "1234", "host": "10.0.0.9"}
|
||||
)
|
||||
|
||||
assert after_code["type"] == "menu", after_code
|
||||
assert client.finish_calls == ["1234"]
|
||||
created = await manager.async_configure(
|
||||
after_code["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry", created
|
||||
# t15: the marker travels with the new entry — the options flow reads it
|
||||
# before it lets the transport run.
|
||||
assert created["options"]["protocol_paired"] is True
|
||||
assert created["options"]["protocol_enabled"] is True
|
||||
|
||||
async def test_skipping_the_setup_pairing_records_not_paired(self, ha, monkeypatch):
|
||||
client = _FakePairingClient()
|
||||
manager, pairing = await self._pair_through_setup(ha, monkeypatch, client)
|
||||
|
||||
after_skip = await manager.async_configure(
|
||||
pairing["flow_id"],
|
||||
{"pairing_code": "", "host": "10.0.0.9", "skip_pairing": True},
|
||||
)
|
||||
|
||||
assert after_skip["type"] == "create_entry", after_skip
|
||||
assert after_skip["options"]["protocol_enabled"] is False
|
||||
assert after_skip["options"]["protocol_paired"] is False
|
||||
assert client.finish_calls == []
|
||||
|
||||
|
||||
class TestRealAppsMultilineField:
|
||||
"""t64: the apps field is a MULTILINE text field — the live bug's cause."""
|
||||
|
||||
async def test_the_rendered_field_carries_the_multiline_text_selector(self, ha):
|
||||
entry = make_entry(ha, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
fields = {f["name"]: f for f in field_list(apps["data_schema"])}
|
||||
assert list(fields) == ["app_name_map"]
|
||||
# A bare `str` field with no selector renders as a SINGLE-LINE input (HA
|
||||
# docs), which is why the live page showed the map flattened and refused
|
||||
# new lines. The field must carry a real selector, and it is the text one;
|
||||
# a selector-less field would have no ``selector`` key at all.
|
||||
assert fields["app_name_map"]["selector"] == {
|
||||
"text": {"multiline": True, "multiple": False}
|
||||
}
|
||||
|
||||
async def test_a_saved_page_keeps_one_row_per_line_and_reopens_unchanged(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, apps = await open_apps_text(ha, entry)
|
||||
|
||||
page = "com.a=Alpha\ncom.b=Beta|http://icons.local/b.png\ncom.c=Gamma"
|
||||
saved = await manager.async_configure(apps["flow_id"], {"app_name_map": page})
|
||||
back = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta",
|
||||
"com.c": "Gamma",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b.png"}
|
||||
|
||||
# reopen over the SAVED options: the same three lines come back, in order
|
||||
reopened = dict(entry.options)
|
||||
reopened.update(created["data"])
|
||||
object.__setattr__(entry, "_options", reopened)
|
||||
_manager, apps2 = await open_apps_text(ha, entry)
|
||||
assert suggested(apps2["data_schema"])["app_name_map"] == page
|
||||
|
||||
|
||||
class TestRealAppsRowEditor:
|
||||
"""t64: add / edit / remove ONE row — HA has no table selector."""
|
||||
|
||||
async def test_the_editor_offers_only_the_rows_that_can_do_something(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
_manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
# nothing stored AND nothing detected: no edit/remove row could do
|
||||
# anything, so none is offered (t64)
|
||||
assert list(apps["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
|
||||
assert "no apps known yet" in apps["description_placeholders"]["apps"]
|
||||
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
|
||||
)
|
||||
_manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
assert list(apps["menu_options"]) == [
|
||||
"apps_text",
|
||||
"apps_add",
|
||||
"apps_edit",
|
||||
"apps_remove",
|
||||
"menu_again",
|
||||
]
|
||||
assert apps["description_placeholders"]["apps"] == (
|
||||
"2 apps known — Alpha (com.a), Beta (com.b)"
|
||||
)
|
||||
|
||||
async def test_the_dropdown_carries_the_detected_apps_and_names_them(self, ha):
|
||||
"""t64: the picked-from list is the detected list, named where known.
|
||||
|
||||
The user's own Shield exposes no device label at all, so the unnamed case
|
||||
is the common one: it must read as unnamed (not as its own id twice), it
|
||||
must still be pickable, and saving a name for it must store the row.
|
||||
"""
|
||||
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
|
||||
entry.runtime_data = TestRealDiscoverFeedback._Manager(
|
||||
[
|
||||
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
|
||||
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
|
||||
]
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
note = apps["description_placeholders"]["apps"]
|
||||
assert note.startswith("3 apps known — ")
|
||||
assert "Kodi (org.xbmc.kodi) [detected]" in note
|
||||
assert "com.unnamed.tv (no name yet) [detected]" in note
|
||||
assert "Alpha (com.a)" in note # a saved row keeps the user's name
|
||||
|
||||
choose = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_edit"})
|
||||
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
|
||||
assert options == [
|
||||
{"value": "com.a", "label": "Alpha (com.a)"},
|
||||
{"value": "org.xbmc.kodi", "label": "Kodi (org.xbmc.kodi)"},
|
||||
{"value": "com.unnamed.tv", "label": "com.unnamed.tv (no name yet)"},
|
||||
]
|
||||
|
||||
form = await manager.async_configure(
|
||||
choose["flow_id"], {"app_package": "com.unnamed.tv"}
|
||||
)
|
||||
assert form["step_id"] == "apps_edit_form"
|
||||
assert "com.unnamed.tv (no name yet)" in form["description_placeholders"]["app"]
|
||||
assert suggested(form["data_schema"])["app_name"] == "" # nothing invented
|
||||
assert suggested(form["data_schema"])["app_icon"] == "" # never the mdi: icon
|
||||
|
||||
edited = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
{"app_name": "Unnamed TV", "app_icon": "http://icons.local/u.png"},
|
||||
)
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.unnamed.tv": "Unnamed TV",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.unnamed.tv": "http://icons.local/u.png"}
|
||||
|
||||
async def test_add_persists_one_row_and_leaves_the_others_alone(self, ha):
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha"}, "host": "10.0.0.9"}
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
|
||||
assert add["type"] == "form"
|
||||
assert add["step_id"] == "apps_add"
|
||||
assert field_names(add["data_schema"]) == ["app_package", "app_name", "app_icon"]
|
||||
|
||||
edited = await manager.async_configure(
|
||||
add["flow_id"],
|
||||
{"app_package": "com.b", "app_name": "Beta", "app_icon": ""},
|
||||
)
|
||||
assert edited["type"] == "menu"
|
||||
assert edited["step_id"] == "apps"
|
||||
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {"com.a": "Alpha", "com.b": "Beta"}
|
||||
assert created["data"]["host"] == "10.0.0.9" # nothing else moved
|
||||
|
||||
async def test_an_invalid_row_is_refused_with_a_message(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
add = await manager.async_configure(apps["flow_id"], {"next_step_id": "apps_add"})
|
||||
|
||||
refused = await manager.async_configure(
|
||||
add["flow_id"],
|
||||
{"app_package": "has spaces", "app_name": "Nope", "app_icon": ""},
|
||||
)
|
||||
|
||||
assert refused["type"] == "form"
|
||||
assert refused["step_id"] == "apps_add"
|
||||
assert refused["errors"] == {"base": "invalid_app_row"}
|
||||
|
||||
async def test_edit_lists_the_stored_rows_and_prefills_the_chosen_one(self, ha):
|
||||
entry = make_entry(
|
||||
ha,
|
||||
options={
|
||||
"app_name_map": {"com.a": "Alpha", "com.b": "Beta"},
|
||||
"app_icon_map": {"com.b": "http://icons.local/b.png"},
|
||||
},
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
choose = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_edit"}
|
||||
)
|
||||
assert choose["type"] == "form"
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
options = field_list(choose["data_schema"])[0]["selector"]["select"]["options"]
|
||||
assert options == [
|
||||
{"value": "com.a", "label": "Alpha (com.a)"},
|
||||
{"value": "com.b", "label": "Beta (com.b)"},
|
||||
]
|
||||
|
||||
form = await manager.async_configure(choose["flow_id"], {"app_package": "com.b"})
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps_edit_form"
|
||||
assert "Beta (com.b)" in form["description_placeholders"]["app"]
|
||||
# PREFILLED from the stored row — name and icon both
|
||||
assert suggested(form["data_schema"])["app_name"] == "Beta"
|
||||
assert suggested(form["data_schema"])["app_icon"] == "http://icons.local/b.png"
|
||||
|
||||
edited = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
{"app_name": "Beta (living room)", "app_icon": "http://icons.local/b2.png"},
|
||||
)
|
||||
assert edited["type"] == "menu" and edited["step_id"] == "apps"
|
||||
back = await manager.async_configure(
|
||||
edited["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta (living room)",
|
||||
}
|
||||
assert created["data"]["app_icon_map"] == {"com.b": "http://icons.local/b2.png"}
|
||||
|
||||
async def test_remove_confirms_first_and_a_cancel_keeps_the_row(self, ha):
|
||||
entry = make_entry(
|
||||
ha, options={"app_name_map": {"com.a": "Alpha", "com.b": "Beta"}}
|
||||
)
|
||||
manager, apps = await open_apps_menu(ha, entry)
|
||||
|
||||
choose = await manager.async_configure(
|
||||
apps["flow_id"], {"next_step_id": "apps_remove"}
|
||||
)
|
||||
assert choose["type"] == "form" and choose["step_id"] == "apps_remove"
|
||||
|
||||
confirm = await manager.async_configure(choose["flow_id"], {"app_package": "com.a"})
|
||||
assert confirm["type"] == "menu"
|
||||
assert confirm["step_id"] == "apps_remove_confirm"
|
||||
assert set(confirm["menu_options"]) == {"apps_remove_yes", "apps_remove_no"}
|
||||
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
|
||||
|
||||
cancelled = await manager.async_configure(
|
||||
confirm["flow_id"], {"next_step_id": "apps_remove_no"}
|
||||
)
|
||||
assert cancelled["type"] == "menu" and cancelled["step_id"] == "apps"
|
||||
|
||||
# ...and the confirmed path drops exactly that row
|
||||
choose2 = await manager.async_configure(
|
||||
cancelled["flow_id"], {"next_step_id": "apps_remove"}
|
||||
)
|
||||
confirm2 = await manager.async_configure(
|
||||
choose2["flow_id"], {"app_package": "com.a"}
|
||||
)
|
||||
after = await manager.async_configure(
|
||||
confirm2["flow_id"], {"next_step_id": "apps_remove_yes"}
|
||||
)
|
||||
assert after["type"] == "menu" and after["step_id"] == "apps"
|
||||
back = await manager.async_configure(
|
||||
after["flow_id"], {"next_step_id": "menu_again"}
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
|
||||
assert created["data"]["app_name_map"] == {"com.b": "Beta"}
|
||||
|
||||
|
||||
class TestRealDiscoverFeedback:
|
||||
"""t64: the Discover row reports WHAT IT DID — never a silent no-op."""
|
||||
|
||||
class _Manager:
|
||||
"""A stand-in connection manager with a canned discovered table."""
|
||||
|
||||
def __init__(self, rows=None, connected=True, fail=False):
|
||||
self.rows = list(rows or [])
|
||||
self.connected = connected
|
||||
self.fail = fail
|
||||
self.refresh_calls: list[bool] = []
|
||||
|
||||
def installed_apps(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
async def async_refresh_installed_apps(self, force=False):
|
||||
self.refresh_calls.append(force)
|
||||
if self.fail:
|
||||
raise RuntimeError("adb went away")
|
||||
return self.installed_apps()
|
||||
|
||||
async def _discover(self, ha, entry):
|
||||
manager, menu = await open_options(ha, entry)
|
||||
return manager, await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "discover"}
|
||||
)
|
||||
|
||||
async def test_an_unreachable_sweep_says_so(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
entry.runtime_data = self._Manager(connected=False, fail=True)
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
assert after["type"] == "menu" and after["step_id"] == "init"
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert "could not reach" in note and "unchanged" in note
|
||||
|
||||
async def test_finding_apps_reports_the_count(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager = self._Manager(
|
||||
[
|
||||
{"package": "com.a", "name": "Alpha", "icon": "mdi:alpha"},
|
||||
{"package": "com.b", "name": "Beta", "icon": "mdi:beta"},
|
||||
]
|
||||
)
|
||||
entry.runtime_data = manager
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert note.startswith("2 apps found on the Shield")
|
||||
assert "updated" in note
|
||||
assert manager.refresh_calls == [True] # explicit force, exactly once
|
||||
|
||||
async def test_finding_nothing_says_the_rows_are_kept(self, ha):
|
||||
entry = make_entry(ha, options={"app_name_map": {"com.a": "Alpha"}})
|
||||
entry.runtime_data = self._Manager([])
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert "no installed apps" in note and "keeps your" in note
|
||||
|
||||
async def test_without_a_manager_it_says_adb_is_not_set_up(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
|
||||
_manager, after = await self._discover(ha, entry)
|
||||
|
||||
note = after["description_placeholders"]["discovery"]
|
||||
assert note == "ADB is not set up for this entry — nothing could be discovered."
|
||||
+405
-29
@@ -36,6 +36,7 @@ from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_APP_ICON_MAP,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
@@ -46,6 +47,7 @@ from nsh.const import (
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
CONF_SCREEN_CAPTURE_ENABLED,
|
||||
CONF_SKIP_PAIRING,
|
||||
DEFAULT_ADB_PORT,
|
||||
SCRIPT_COMMANDS,
|
||||
@@ -248,7 +250,7 @@ def _install_stubs() -> None:
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player", SENSOR="sensor", REMOTE="remote",
|
||||
BUTTON="button",
|
||||
BUTTON="button", IMAGE="image",
|
||||
)
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
@@ -1526,15 +1528,27 @@ class TestCopyContract:
|
||||
flow.config_entry = SimpleNamespace(options={}, entry_id="e1", title="Shield")
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
async def walk(step: str) -> dict:
|
||||
"""The rendered screens from ``step`` down, keyed by the i18n step id."""
|
||||
opened = await getattr(flow, f"async_step_{step}")(None)
|
||||
if opened["type"] == "menu":
|
||||
nested: dict = {step: ("menu", opened)}
|
||||
for row in opened["menu_options"]:
|
||||
if row in OPTIONS_ACTION_ROWS:
|
||||
continue
|
||||
nested.update(await walk(row))
|
||||
return nested
|
||||
return {step: ("form", opened)}
|
||||
|
||||
menu = await flow.async_step_init(None)
|
||||
# the rendered sub-screens, keyed by the i18n step id the menu row maps to
|
||||
rendered = {
|
||||
step: vol_mirror.declared_fields(
|
||||
(await getattr(flow, f"async_step_{step}")(None))["data_schema"]
|
||||
)
|
||||
for step in menu["menu_options"]
|
||||
if step not in ("finish", "discover")
|
||||
}
|
||||
# t64: the Apps sub-screen is itself a MENU of row actions, so the walk
|
||||
# goes as deep as the screen does — a nested step is checked exactly like
|
||||
# a top-level one, and every row a nested menu offers must have a label.
|
||||
rendered: dict = {}
|
||||
for step in menu["menu_options"]:
|
||||
if step in OPTIONS_ACTION_ROWS:
|
||||
continue
|
||||
rendered.update(await walk(step))
|
||||
|
||||
for file in (_strings(), _en_strings()):
|
||||
menu_step = file["options"]["step"]["init"]
|
||||
@@ -1546,12 +1560,18 @@ class TestCopyContract:
|
||||
assert option in menu_step.get("menu_options", {}), f"menu row {option}"
|
||||
|
||||
steps = file["options"]["step"]
|
||||
for step, fields in rendered.items():
|
||||
for step, (kind, opened) in rendered.items():
|
||||
# a sub-screen's i18n step key is its menu row id (HA dispatches
|
||||
# a submitted form by its own step_id, so the two must match)
|
||||
assert steps[step]["title"], step
|
||||
assert steps[step]["description"], step
|
||||
for field in fields:
|
||||
if kind == "menu":
|
||||
for row in opened["menu_options"]:
|
||||
assert row in steps[step].get("menu_options", {}), (
|
||||
f"{step} row {row} has no label"
|
||||
)
|
||||
continue
|
||||
for field in vol_mirror.declared_fields(opened["data_schema"]):
|
||||
assert field in steps[step]["data"], f"{step}.{field} has no label"
|
||||
|
||||
|
||||
@@ -1584,9 +1604,12 @@ OPTIONS_SUBSTEP_FIELDS = {
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_LOGCAT_EVENTS,
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
CONF_SCREEN_CAPTURE_ENABLED,
|
||||
},
|
||||
"receiver": {"receiver_entity", "receiver_input"},
|
||||
"apps": {"app_name_map", "app_icon_map"},
|
||||
# t64: the apps table lives in its OWN step now (the Apps sub-screen is a menu
|
||||
# of row actions), so the field that owns the map is `apps_text`.
|
||||
"apps_text": {"app_name_map", "app_icon_map"},
|
||||
"scripts": {
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
@@ -1595,6 +1618,39 @@ OPTIONS_SUBSTEP_FIELDS = {
|
||||
},
|
||||
}
|
||||
|
||||
#: t64: the Apps editor's rows live one level deeper — the Apps sub-screen is a
|
||||
#: MENU of row actions, exactly as the browser navigates it: open the parent row,
|
||||
#: then the row the user picked.
|
||||
OPTIONS_NESTED_STEPS = {
|
||||
"apps_text": "apps",
|
||||
"apps_add": "apps",
|
||||
"apps_edit": "apps",
|
||||
"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",
|
||||
"intents_remove_yes",
|
||||
"intents_remove_no",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _options_substep_of(key: str) -> str:
|
||||
for step, fields in OPTIONS_SUBSTEP_FIELDS.items():
|
||||
@@ -1619,11 +1675,20 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
grouped.setdefault(_options_substep_of(key), {})[key] = value
|
||||
result = await flow.async_step_init(None)
|
||||
for step, sub_payload in grouped.items():
|
||||
parent = OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], (
|
||||
f"{parent} is not offered by the menu: {result['menu_options']}"
|
||||
)
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], (
|
||||
f"{step} is not offered by the menu: {result['menu_options']}"
|
||||
)
|
||||
result = await getattr(flow, f"async_step_{step}")(sub_payload)
|
||||
if save:
|
||||
# t64: a nested row (the apps table) returns to the APPS menu, so walk
|
||||
# back the way the "Back to the main menu" row does before committing.
|
||||
result = await flow.async_step_menu_again()
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await flow.async_step_finish(None)
|
||||
return result
|
||||
@@ -1642,6 +1707,10 @@ def _all_script_fields(value):
|
||||
async def _options_open(flow, step: str):
|
||||
"""Choose a sub-screen from the menu and open its form."""
|
||||
result = await flow.async_step_init(None)
|
||||
parent = OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
return await getattr(flow, f"async_step_{step}")(None)
|
||||
|
||||
@@ -2012,6 +2081,7 @@ class TestOptionsMenu:
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"intents",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
@@ -2036,16 +2106,22 @@ class TestOptionsMenu:
|
||||
assert hasattr(flow, f"async_step_{row}"), row
|
||||
if row in action_rows:
|
||||
continue
|
||||
form = await getattr(flow, f"async_step_{row}")(None)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == row, form["step_id"]
|
||||
opened = await getattr(flow, f"async_step_{row}")(None)
|
||||
# a sub-screen is a FORM — or, for Apps (t64), a MENU of row actions
|
||||
assert opened["type"] in ("form", "menu"), (row, opened)
|
||||
assert opened["step_id"] == row, opened["step_id"]
|
||||
if opened["type"] == "menu":
|
||||
# a nested menu's rows are dispatched by name as well
|
||||
for nested in opened["menu_options"]:
|
||||
assert hasattr(flow, f"async_step_{nested}"), (row, nested)
|
||||
|
||||
async def test_every_sub_screen_is_reachable_and_returns_to_the_menu(self, tmp_path):
|
||||
flow = _options_flow(tmp_path)
|
||||
|
||||
for row in ("connection", "receiver", "apps", "scripts"): # sub-screens
|
||||
opened = await _options_open(flow, row)
|
||||
assert opened["type"] == "form"
|
||||
# a sub-screen is a form, except Apps: a MENU of row actions (t64)
|
||||
assert opened["type"] in ("form", "menu"), (row, opened)
|
||||
# a submission that changes nothing still returns to the menu
|
||||
back = await getattr(flow, f"async_step_{row}")({})
|
||||
assert back["type"] == "menu", (row, back)
|
||||
@@ -2090,7 +2166,7 @@ class TestAppsTable:
|
||||
{"app_name_map": {"ar.tvplayer.tv": "TiviMate", "com.plex.hdx": "Plex"}},
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
suggested = form["data_schema"]["__suggested__"]
|
||||
|
||||
assert isinstance(suggested["app_name_map"], str)
|
||||
@@ -2111,7 +2187,7 @@ class TestAppsTable:
|
||||
|
||||
# re-open: the row (icon included) comes back in the text field
|
||||
reopened = _options_flow(tmp_path, result["data"])
|
||||
form = await _options_open(reopened, "apps")
|
||||
form = await _options_open(reopened, "apps_text")
|
||||
assert (
|
||||
form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
== "com.plex.hdx=Plex|http://icons.local/plex.png"
|
||||
@@ -2140,10 +2216,10 @@ class TestAppsTable:
|
||||
async def test_the_empty_state_renders_without_data_or_a_crash(self, tmp_path):
|
||||
flow = _options_flow(tmp_path, options={})
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps"
|
||||
assert form["step_id"] == "apps_text"
|
||||
assert form["errors"] == {}
|
||||
assert list(vol_mirror.declared_fields(form["data_schema"])) == ["app_name_map"]
|
||||
# no discovery data yet (there is no manager in this harness), so the
|
||||
@@ -2190,12 +2266,13 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
[
|
||||
{"package": "ar.tvplayer.tv", "name": "TiviMate", "icon": "mdi:television-classic"},
|
||||
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||
{"package": "com.example.handmade", "name": "com.example.handmade",
|
||||
"icon": "mdi:television"},
|
||||
# t66: a discovered app nothing can name comes through as name ""
|
||||
# (the sweep no longer repeats the package id as a name)
|
||||
{"package": "com.example.handmade", "name": "", "icon": "mdi:television"},
|
||||
],
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
text = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
|
||||
lines = text.split("\n")
|
||||
@@ -2203,7 +2280,9 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
assert lines[0] == "ar.tvplayer.tv=Telly"
|
||||
# every discovered app is a row too — this is the live bug
|
||||
assert "org.xbmc.kodi=Kodi" in lines
|
||||
assert "com.example.handmade=com.example.handmade" in lines
|
||||
# ...but an app nothing names is NOT pre-filled as `pkg=pkg` (t66): the id
|
||||
# would be stored as if it were a name. The row editor still offers it.
|
||||
assert not [line for line in lines if line.startswith("com.example.handmade=")]
|
||||
# the user's name wins over the device label for the same package
|
||||
assert not [line for line in lines if line == "ar.tvplayer.tv=TiviMate"]
|
||||
# ...and the discovered icon is NEVER written into the icon column
|
||||
@@ -2216,7 +2295,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["data_schema"]["__suggested__"]["app_name_map"] == (
|
||||
"com.netflix.ninja=Netflix"
|
||||
@@ -2227,7 +2306,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
tmp_path, {"app_name_map": {"com.a": "A"}}, [], connected=False
|
||||
)
|
||||
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
|
||||
assert form["data_schema"]["__suggested__"]["app_name_map"] == "com.a=A"
|
||||
|
||||
@@ -2235,7 +2314,7 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
"""A form render must never sweep — discovery is an explicit action."""
|
||||
flow, manager = self._flow_with(tmp_path, {}, [])
|
||||
|
||||
await _options_open(flow, "apps")
|
||||
await _options_open(flow, "apps_text")
|
||||
await flow.async_step_init(None)
|
||||
|
||||
assert manager.refresh_calls == []
|
||||
@@ -2292,15 +2371,271 @@ class TestDiscoveryFeedsTheAppsTable:
|
||||
{},
|
||||
[{"package": "com.netflix.ninja", "name": "Netflix", "icon": "mdi:netflix"}],
|
||||
)
|
||||
form = await _options_open(flow, "apps")
|
||||
form = await _options_open(flow, "apps_text")
|
||||
submitted = form["data_schema"]["__suggested__"]["app_name_map"]
|
||||
|
||||
await flow.async_step_apps({CONF_APP_NAME_MAP: submitted})
|
||||
await flow.async_step_apps_text({CONF_APP_NAME_MAP: submitted})
|
||||
result = await flow.async_step_finish(None)
|
||||
|
||||
assert result["data"][CONF_APP_NAME_MAP] == {"com.netflix.ninja": "Netflix"}
|
||||
|
||||
|
||||
#: t64: the apps ROW EDITOR's field names. They are UI only — a projection of one
|
||||
#: ``package=name[|icon]`` row — so they are asserted against the flow module's own
|
||||
#: constants (below) instead of being trusted to match.
|
||||
APP_PACKAGE = "app_package"
|
||||
APP_NAME = "app_name"
|
||||
APP_ICON = "app_icon"
|
||||
|
||||
|
||||
class TestAppsRowEditor:
|
||||
"""t64: HA has no table selector, so a row is edited one step at a time."""
|
||||
|
||||
def _flow(self, tmp_path, options=None):
|
||||
return _options_flow(tmp_path, options or {})
|
||||
|
||||
async def test_the_field_is_the_multiline_text_selector(self, tmp_path):
|
||||
"""The mechanical cause of the live report, pinned without HA.
|
||||
|
||||
A selector-less ``str`` field renders as a SINGLE-LINE input, so the map
|
||||
arrived flattened with nowhere to add a row. The field must carry the text
|
||||
selector, and it must be the MULTILINE one — the flag the frontend reads
|
||||
to render a textarea instead of a one-line box.
|
||||
"""
|
||||
flow = self._flow(tmp_path)
|
||||
form = await _options_open(flow, "apps_text")
|
||||
module = _form_module()
|
||||
assert (
|
||||
module.APP_FIELD_PACKAGE,
|
||||
module.APP_FIELD_NAME,
|
||||
module.APP_FIELD_ICON,
|
||||
) == (APP_PACKAGE, APP_NAME, APP_ICON)
|
||||
|
||||
field = form["data_schema"][CONF_APP_NAME_MAP]
|
||||
assert isinstance(field, entity_stublib.TextSelectorStub), field
|
||||
assert field.config.multiline is True
|
||||
|
||||
async def test_the_dropdown_lists_detected_apps_too_and_labels_them(self, tmp_path):
|
||||
"""t64: the entry point is the DETECTED list, named where a name is known.
|
||||
|
||||
The dropdown is what the user picks from, so it carries the saved rows AND
|
||||
the apps discovery found; an app whose device label is missing is labelled
|
||||
"(no name yet)" rather than with its own id twice, and the icon column is
|
||||
never prefilled from the device icon (that is an ``mdi:`` name, which the
|
||||
column does not accept).
|
||||
"""
|
||||
flow = _options_flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
||||
flow.config_entry.runtime_data = TestDiscoverSaysWhatHappened._Manager(
|
||||
[
|
||||
{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"},
|
||||
{"package": "com.unnamed.tv", "name": "com.unnamed.tv", "icon": ""},
|
||||
{"package": "com.a", "name": "Wrong name", "icon": "mdi:x"},
|
||||
]
|
||||
)
|
||||
|
||||
placeholder = flow._apps_placeholders()["apps"]
|
||||
assert placeholder.startswith("3 apps known — ")
|
||||
assert "Kodi (org.xbmc.kodi) [detected]" in placeholder
|
||||
assert "com.unnamed.tv (no name yet) [detected]" in placeholder
|
||||
assert "Alpha (com.a)" in placeholder # the saved row keeps the user's name
|
||||
|
||||
choose = await flow.async_step_apps_edit(None)
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
|
||||
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.unnamed.tv"})
|
||||
suggested_values = form["data_schema"]["__suggested__"]
|
||||
assert suggested_values[APP_NAME] == "" # no device label: nothing invented
|
||||
assert suggested_values[APP_ICON] == "" # never the mdi: device icon
|
||||
|
||||
await flow.async_step_apps_edit_form(
|
||||
{APP_NAME: "Unnamed TV", APP_ICON: "http://icons.local/u.png"}
|
||||
)
|
||||
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.unnamed.tv": "Unnamed TV",
|
||||
}
|
||||
|
||||
async def test_only_the_rows_that_can_act_are_offered(self, tmp_path):
|
||||
empty = self._flow(tmp_path)
|
||||
menu = await empty.async_step_apps(None)
|
||||
|
||||
assert list(menu["menu_options"]) == ["apps_text", "apps_add", "menu_again"]
|
||||
assert empty._apps_placeholders()["apps"].startswith("no apps known yet")
|
||||
|
||||
flow = self._flow(tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}})
|
||||
menu = await flow.async_step_apps(None)
|
||||
|
||||
assert list(menu["menu_options"]) == [
|
||||
"apps_text",
|
||||
"apps_add",
|
||||
"apps_edit",
|
||||
"apps_remove",
|
||||
"menu_again",
|
||||
]
|
||||
assert flow._apps_placeholders()["apps"] == "1 app known — Alpha (com.a)"
|
||||
|
||||
async def test_add_writes_one_row_and_leaves_the_others_alone(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_HOST: "10.0.0.9"}
|
||||
)
|
||||
|
||||
result = await flow.async_step_apps_add(
|
||||
{
|
||||
APP_PACKAGE: "com.b",
|
||||
APP_NAME: "Beta",
|
||||
APP_ICON: "http://icons.local/b.png",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu" # back at the editor, nothing committed yet
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta",
|
||||
}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
||||
"com.b": "http://icons.local/b.png"
|
||||
}
|
||||
assert flow._options_dict[CONF_HOST] == "10.0.0.9" # another screen's field
|
||||
|
||||
async def test_an_invalid_row_is_refused_and_stores_nothing(self, tmp_path):
|
||||
flow = self._flow(tmp_path)
|
||||
|
||||
form = await flow.async_step_apps_add(
|
||||
{APP_PACKAGE: "has spaces", APP_NAME: "Nope", APP_ICON: ""}
|
||||
)
|
||||
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "apps_add"
|
||||
assert form["errors"] == {"base": "invalid_app_row"}
|
||||
assert CONF_APP_NAME_MAP not in flow._options_dict
|
||||
|
||||
async def test_edit_prefills_the_stored_row_and_changes_only_it(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path,
|
||||
{
|
||||
CONF_APP_NAME_MAP: {"com.a": "Alpha", "com.b": "Beta"},
|
||||
CONF_APP_ICON_MAP: {"com.b": "http://icons.local/b.png"},
|
||||
},
|
||||
)
|
||||
|
||||
choose = await flow.async_step_apps_edit(None)
|
||||
assert choose["step_id"] == "apps_edit"
|
||||
|
||||
form = await flow.async_step_apps_edit({APP_PACKAGE: "com.b"})
|
||||
suggested_values = form["data_schema"]["__suggested__"]
|
||||
assert suggested_values[APP_NAME] == "Beta"
|
||||
assert suggested_values[APP_ICON] == "http://icons.local/b.png"
|
||||
|
||||
result = await flow.async_step_apps_edit_form(
|
||||
{APP_NAME: "Beta (living room)", APP_ICON: "http://icons.local/b2.png"}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {
|
||||
"com.a": "Alpha",
|
||||
"com.b": "Beta (living room)",
|
||||
}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {
|
||||
"com.b": "http://icons.local/b2.png"
|
||||
}
|
||||
|
||||
async def test_remove_needs_the_confirmation_row(self, tmp_path):
|
||||
flow = self._flow(
|
||||
tmp_path, {CONF_APP_NAME_MAP: {"com.a": "Alpha"}, CONF_APP_ICON_MAP: {}}
|
||||
)
|
||||
|
||||
choose = await flow.async_step_apps_remove(None)
|
||||
assert choose["step_id"] == "apps_remove"
|
||||
|
||||
confirm = await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
||||
assert confirm["type"] == "menu"
|
||||
assert confirm["step_id"] == "apps_remove_confirm"
|
||||
assert "Alpha (com.a)" in confirm["description_placeholders"]["app"]
|
||||
|
||||
kept = await flow.async_step_apps_remove_no(None)
|
||||
assert kept["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {"com.a": "Alpha"}
|
||||
|
||||
await flow.async_step_apps_remove({APP_PACKAGE: "com.a"})
|
||||
dropped = await flow.async_step_apps_remove_yes(None)
|
||||
|
||||
assert dropped["type"] == "menu"
|
||||
assert flow._options_dict[CONF_APP_NAME_MAP] == {}
|
||||
assert flow._options_dict[CONF_APP_ICON_MAP] == {}
|
||||
|
||||
|
||||
class TestDiscoverSaysWhatHappened:
|
||||
"""t64: "Discover ... seems to load then do nothing" must be impossible."""
|
||||
|
||||
class _Manager:
|
||||
def __init__(self, rows=None, connected=True, fail=False):
|
||||
self.rows = list(rows or [])
|
||||
self.connected = connected
|
||||
self.fail = fail
|
||||
self.refresh_calls: list[bool] = []
|
||||
|
||||
def installed_apps(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
async def async_refresh_installed_apps(self, force=False):
|
||||
self.refresh_calls.append(force)
|
||||
if self.fail:
|
||||
raise RuntimeError("adb went away")
|
||||
return self.installed_apps()
|
||||
|
||||
def _flow_with(self, tmp_path, manager):
|
||||
flow = _options_flow(tmp_path, {})
|
||||
flow.config_entry.runtime_data = manager
|
||||
return flow
|
||||
|
||||
async def test_no_manager_says_adb_is_not_set_up(self, tmp_path):
|
||||
flow = _options_flow(tmp_path, {})
|
||||
|
||||
await flow.async_step_discover(None)
|
||||
|
||||
assert flow._menu_placeholders()["discovery"] == (
|
||||
"ADB is not set up for this entry — nothing could be discovered."
|
||||
)
|
||||
|
||||
async def test_finding_apps_reports_the_count_and_updates_the_table(
|
||||
self, tmp_path
|
||||
):
|
||||
manager = self._Manager(
|
||||
[{"package": "org.xbmc.kodi", "name": "Kodi", "icon": "mdi:kodi"}]
|
||||
)
|
||||
flow = self._flow_with(tmp_path, manager)
|
||||
|
||||
result = await flow.async_step_discover(None)
|
||||
|
||||
assert result["type"] == "menu"
|
||||
assert manager.refresh_calls == [True]
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert note.startswith("1 apps found on the Shield") # the t57 wording
|
||||
assert "updated" in note
|
||||
form = await _options_open(flow, "apps_text")
|
||||
assert "org.xbmc.kodi=Kodi" in form["data_schema"]["__suggested__"][
|
||||
CONF_APP_NAME_MAP
|
||||
]
|
||||
|
||||
async def test_finding_nothing_says_the_saved_rows_stay(self, tmp_path):
|
||||
flow = self._flow_with(tmp_path, self._Manager([]))
|
||||
|
||||
await flow.async_step_discover(None)
|
||||
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert "no installed apps" in note and "keeps your" in note
|
||||
|
||||
async def test_a_failed_sweep_says_so_instead_of_nothing(self, tmp_path):
|
||||
flow = self._flow_with(tmp_path, self._Manager([], connected=False, fail=True))
|
||||
|
||||
result = await flow.async_step_discover(None)
|
||||
|
||||
assert result["type"] == "menu" # no traceback, no dead end
|
||||
note = flow._menu_placeholders()["discovery"]
|
||||
assert "could not reach" in note and "unchanged" in note
|
||||
|
||||
|
||||
class TestReceiverAcceptsASwitch:
|
||||
"""t56: a soundbar that only knows on/off is a valid receiver."""
|
||||
|
||||
@@ -2451,3 +2786,44 @@ class TestSetupMenu:
|
||||
assert flow._host() == "10.0.0.77"
|
||||
assert result["options"].get("receiver_entity") == "switch.soundbar"
|
||||
assert result["options"].get("receiver_input") == "HDMI 1"
|
||||
|
||||
|
||||
class TestScreenCaptureOption:
|
||||
"""t66: the screen-capture image entity is opt-in, and OFF is the default."""
|
||||
|
||||
async def test_the_connection_screen_offers_it_and_defaults_off(self, tmp_path):
|
||||
"""Default OFF at the SCHEMA level — the form the user is shown."""
|
||||
flow = _options_flow(tmp_path)
|
||||
|
||||
result = await _options_open(flow, "connection")
|
||||
|
||||
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
||||
assert defaults[CONF_SCREEN_CAPTURE_ENABLED] is False
|
||||
|
||||
async def test_an_untouched_toggle_stays_off(self, tmp_path):
|
||||
"""A save that never renders the screen-capture row must not invent it."""
|
||||
flow = _options_flow(tmp_path)
|
||||
|
||||
result = await _options_submit(flow, {CONF_HOST: "10.0.0.9"}, save=True)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert not result["data"].get(CONF_SCREEN_CAPTURE_ENABLED)
|
||||
|
||||
async def test_switching_it_on_is_stored(self, tmp_path):
|
||||
flow = _options_flow(tmp_path)
|
||||
|
||||
result = await _options_submit(
|
||||
flow, {CONF_SCREEN_CAPTURE_ENABLED: True}, save=True
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is True
|
||||
|
||||
async def test_it_can_be_switched_back_off(self, tmp_path):
|
||||
flow = _options_flow(tmp_path, {CONF_SCREEN_CAPTURE_ENABLED: True})
|
||||
|
||||
result = await _options_submit(
|
||||
flow, {CONF_SCREEN_CAPTURE_ENABLED: False}, save=True
|
||||
)
|
||||
|
||||
assert result["data"][CONF_SCREEN_CAPTURE_ENABLED] is False
|
||||
+40
-11
@@ -18,7 +18,12 @@ from nsh.connection import (
|
||||
ShieldConnectionManager,
|
||||
StateStore,
|
||||
)
|
||||
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
|
||||
from nsh.const import (
|
||||
ADB_POLL_INTERVAL,
|
||||
APP_LABEL_EXIT_MARKER,
|
||||
APP_LABEL_GREP_SHAPES,
|
||||
CONF_ADB_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
def test_connection_module_depends_on_no_homeassistant():
|
||||
@@ -100,8 +105,28 @@ class FakePoller:
|
||||
return self.connected
|
||||
|
||||
|
||||
def _adb_entry(**options):
|
||||
"""A realistic entry: its own option says ADB is ON (t23/O1).
|
||||
|
||||
The manager is ALWAYS built with an entry in production, and an entry whose
|
||||
options do not say ``adb_enabled: True`` means ADB is switched off — the gate
|
||||
every device path now asks directly (``connection._adb_disabled``). A harness
|
||||
that passed ``entry=None`` was therefore modelling an ADB-off entry while
|
||||
expecting traffic to flow, which is exactly the combination the t22 review
|
||||
measured. Tests that WANT the disabled behaviour build their own entry.
|
||||
"""
|
||||
return types.SimpleNamespace(
|
||||
options={CONF_ADB_ENABLED: True, **options},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
|
||||
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_adb_entry(), poller=poller, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestStateStore:
|
||||
@@ -146,7 +171,7 @@ class TestAppLabelCache:
|
||||
"application-label-de: 'TiviMate DE'"
|
||||
)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||
hass=None, entry=_adb_entry(), poller=poller, clock=(clock or (lambda: now))
|
||||
)
|
||||
return manager, now
|
||||
|
||||
@@ -310,12 +335,16 @@ class TestAppLabelCache:
|
||||
|
||||
await asyncio.sleep(0) # drain the fetch task
|
||||
|
||||
# t57: the dump carries the exit marker too, so a blank answer can be
|
||||
# told from a FAILED fetch; the package is still quoted
|
||||
# t57: the probe carries the exit marker too, so a blank answer can be told
|
||||
# from a FAILED fetch; the package is still quoted. t66: the label candidates
|
||||
# are filtered ON THE DEVICE, and the 2241-line dump is never truncated —
|
||||
# `head -300` cut it before the package section on the user's Shield.
|
||||
assert manager._poller.shell_calls == [
|
||||
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300; "
|
||||
f"pm dump {shlex.quote(wicked)} 2>/dev/null | "
|
||||
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
|
||||
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||
]
|
||||
assert "head -300" not in manager._poller.shell_calls[0]
|
||||
|
||||
|
||||
class TestLogcatStream:
|
||||
@@ -328,9 +357,9 @@ class TestLogcatStream:
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||
hass=None, entry=_adb_entry(), poller=poller, clock=(clock or (lambda: now))
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": bool(lines)})
|
||||
manager.entry = LEntry(options={"logcat_events": bool(lines), CONF_ADB_ENABLED: True})
|
||||
lines = lines or []
|
||||
|
||||
async def _open():
|
||||
@@ -424,9 +453,9 @@ class TestLogcatStream:
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
|
||||
hass=None, entry=_adb_entry(), poller=poller, clock=lambda: 1000.0
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": True})
|
||||
manager.entry = LEntry(options={"logcat_events": True, CONF_ADB_ENABLED: True})
|
||||
|
||||
# the START record spans two stream lines (wrapped cmp=)
|
||||
lines = [
|
||||
@@ -829,7 +858,7 @@ class TestLifecycle:
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller_factory=lambda: poller
|
||||
hass=None, entry=_adb_entry(), poller_factory=lambda: poller
|
||||
)
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
+259
-1
@@ -12,7 +12,9 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -265,6 +267,10 @@ def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
|
||||
states=states,
|
||||
data={},
|
||||
config=SimpleNamespace(components=set()),
|
||||
# t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which
|
||||
# registers a shutdown listener on ``hass.bus``. The fake hass carries
|
||||
# just enough of that surface for the real base class to construct.
|
||||
bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)),
|
||||
)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
|
||||
@@ -315,6 +321,12 @@ _REAL_HA_MATRIX = (
|
||||
),
|
||||
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||
(
|
||||
"image",
|
||||
"NvidiaShieldScreenImage",
|
||||
{"screen_capture_enabled": True},
|
||||
"image.shield_screen_capture",
|
||||
),
|
||||
)
|
||||
|
||||
#: Classified as an entity by the sweep, but an abstract intermediate base HA
|
||||
@@ -357,9 +369,121 @@ def test_the_media_player_extra_attributes_reach_the_state_machine():
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||
# t15: ``app_source`` names the tier that produced the app identity, so the
|
||||
# protocol push can never look like the ADB reading it outranked.
|
||||
assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
def test_the_remote_entity_publishes_the_activity_contract_under_real_ha():
|
||||
"""t15: the app/activity picker contract, measured against real HA.
|
||||
|
||||
Core androidtv_remote's remote is the parity target. HA renders the picker from
|
||||
``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``,
|
||||
published through the base class's FINAL ``state_attributes`` — so this asserts
|
||||
what the STATE MACHINE receives, not what our own attributes happen to say. The
|
||||
transport evidence rides the same entity, which is what makes "paired?" and
|
||||
"switched on?" answerable without a device test.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
from homeassistant.components.remote import RemoteEntityFeature
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"remote",
|
||||
"NvidiaShieldRemoteEntity",
|
||||
options={
|
||||
"app_name_map": {
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
}
|
||||
},
|
||||
entity_id="remote.shield",
|
||||
)
|
||||
|
||||
assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's
|
||||
assert entity.activity_list == ["Netflix", "TiviMate"]
|
||||
assert entity.current_activity is None, "nothing is known yet — never fabricated"
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate"]
|
||||
assert attrs["current_activity"] is None
|
||||
# ...and the transport's real state, on the same entity (no runtime_data here,
|
||||
# so there is no transport at all — which must read as "off", not as unpaired)
|
||||
assert attrs["protocol_state"] == "off"
|
||||
assert attrs["protocol_connected"] is False
|
||||
assert attrs["protocol_ever_authenticated"] is False
|
||||
assert attrs["power_on_route"] == "protocol"
|
||||
assert attrs["protocol_current_app_seen"] is False
|
||||
|
||||
# The LIVE update paths our entity uses: HA's own metaclass turns the class-level
|
||||
# ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache
|
||||
# invalidation, so both assignments must take effect on the published state. A
|
||||
# private attribute name would leave ``activity_list``/``current_activity`` frozen
|
||||
# at their first value — silent in a unit test, wrong in a dashboard.
|
||||
entity._attr_current_activity = "Netflix"
|
||||
entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
assert entity.current_activity == "Netflix"
|
||||
assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["current_activity"] == "Netflix"
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
|
||||
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
|
||||
_SCREEN_PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
|
||||
"Af+4BDXgAAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
def test_the_screen_capture_entity_serves_png_bytes_under_real_ha():
|
||||
"""t66: HA's OWN image pieces accept what the capture path produces.
|
||||
|
||||
The stub suite proves the logic; this proves the contract with real HA: the
|
||||
real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image
|
||||
proxy runs before it will serve bytes), and the real state write — whose
|
||||
recorded state must be the capture TIMESTAMP and whose attributes must never
|
||||
carry the image itself (the "never a state source" boundary, measured).
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
import base64
|
||||
|
||||
from homeassistant.components.image import valid_image_content_type
|
||||
|
||||
class _Poller:
|
||||
connected = True
|
||||
|
||||
async def shell(self, cmd):
|
||||
assert cmd == "screencap -p | base64"
|
||||
return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"image",
|
||||
"NvidiaShieldScreenImage",
|
||||
options={"screen_capture_enabled": True},
|
||||
entity_id="image.shield_screen_capture",
|
||||
)
|
||||
entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller())
|
||||
|
||||
data = asyncio.run(entity.async_image())
|
||||
|
||||
assert isinstance(data, bytes)
|
||||
assert data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
assert valid_image_content_type(entity.content_type) == "image/png"
|
||||
|
||||
written_id, state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert written_id == "image.shield_screen_capture"
|
||||
# HA's image state IS ``image_last_updated`` — the timestamp, never the bytes
|
||||
assert state == entity.image_last_updated.isoformat()
|
||||
assert attrs["capture_status"] == "ok"
|
||||
assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values())
|
||||
|
||||
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
|
||||
"""A new entity class must join the matrix above, or this test fails.
|
||||
|
||||
@@ -480,3 +604,137 @@ def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch
|
||||
assert any("call_args" in line for line in fewer.floor_violations()), (
|
||||
fewer.floor_violations()
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------- t63: the media-browser root (real HA)
|
||||
#
|
||||
# The live report ("Browse media seems to spin and never finish loading") came
|
||||
# from what async_browse_media HANDED BACK, not from the handler: our method
|
||||
# returns with no awaits, so nothing inside it can hang. HA's handler only
|
||||
# forwards the payload, which makes the defect visible on one path only:
|
||||
#
|
||||
# websocket_browse_media -> player.async_browse_media -> payload.as_dict()
|
||||
#
|
||||
# The frontend bundle is NOT installed here (no hass_frontend, and there is no
|
||||
# browser to drive), so the rendered dialog itself cannot be exercised. What
|
||||
# these tests prove is that backend path end to end, decorators included. The
|
||||
# pre-fix payload carried ``media_content_id=None`` — a node shape no core
|
||||
# integration ever serves, and the assertions below fail on it.
|
||||
|
||||
|
||||
class _RecordingConnection:
|
||||
"""The pieces of websocket_api.ActiveConnection the browse handler calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.results: list = []
|
||||
self.messages: list = []
|
||||
self.exceptions: list = []
|
||||
|
||||
def send_result(self, msg_id, result):
|
||||
self.results.append((msg_id, result))
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def async_handle_exception(self, msg, err):
|
||||
self.exceptions.append(err)
|
||||
|
||||
|
||||
def _wire_the_browser(entity):
|
||||
"""Give the entity's own hass what websocket_browse_media walks.
|
||||
|
||||
``hass.data[DATA_COMPONENT].get_entity`` is how the handler finds the player,
|
||||
and ``async_create_background_task`` is how ``@websocket_api.async_response``
|
||||
schedules the real coroutine — both real shapes, so the DECORATED handler
|
||||
runs unmodified.
|
||||
"""
|
||||
from homeassistant.components.media_player import DATA_COMPONENT
|
||||
|
||||
scheduled: list = []
|
||||
|
||||
def async_create_background_task(coro, name, *, eager_start=False):
|
||||
task = asyncio.create_task(coro)
|
||||
scheduled.append(task)
|
||||
return task
|
||||
|
||||
entity.hass.data[DATA_COMPONENT] = SimpleNamespace(
|
||||
get_entity=lambda entity_id: entity if entity_id == entity.entity_id else None
|
||||
)
|
||||
entity.hass.async_create_background_task = async_create_background_task
|
||||
return scheduled
|
||||
|
||||
|
||||
async def _browse_over_the_real_handler(entity, scheduled, *, content_id=None,
|
||||
content_type=None):
|
||||
"""Send the real websocket command and await the task it schedules."""
|
||||
from homeassistant.components.media_player import websocket_browse_media
|
||||
|
||||
connection = _RecordingConnection()
|
||||
msg = {"id": 7, "type": "media_player/browse_media", "entity_id": entity.entity_id}
|
||||
if content_id is not None or content_type is not None:
|
||||
msg["media_content_id"] = content_id
|
||||
msg["media_content_type"] = content_type
|
||||
|
||||
websocket_browse_media(entity.hass, connection, msg) # schedules the coroutine
|
||||
await asyncio.gather(*scheduled)
|
||||
|
||||
assert connection.exceptions == [], connection.exceptions
|
||||
assert connection.messages == [], connection.messages
|
||||
assert len(connection.results) == 1, connection.results
|
||||
return connection.results[0][1]
|
||||
|
||||
|
||||
async def test_the_real_websocket_handler_serves_a_string_id_root():
|
||||
"""t63 regression: the browse root carries a real STRING id, not None.
|
||||
|
||||
Every core media_player platform serves a string id (a literal, a module
|
||||
constant or ""), because the frontend keys the node it is loading on that id;
|
||||
the live dialog re-requested the same node for ever with a null one.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, _states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
scheduled = _wire_the_browser(entity)
|
||||
|
||||
payload = await _browse_over_the_real_handler(entity, scheduled)
|
||||
|
||||
assert isinstance(payload["media_content_id"], str)
|
||||
assert payload["media_content_id"] == "root"
|
||||
assert payload["media_content_type"] == "directory" # not MediaType.APPS
|
||||
assert payload["children"] == []
|
||||
assert payload["can_expand"] is False
|
||||
assert payload["can_play"] is False
|
||||
assert payload["media_class"] == "directory"
|
||||
# the payload the frontend receives has to survive the websocket's own encode
|
||||
assert json.loads(json.dumps(payload)) == payload
|
||||
|
||||
|
||||
async def test_the_real_handler_answers_every_id_with_the_same_root():
|
||||
"""Idempotent: the root id, None and a stale child id give ONE payload.
|
||||
|
||||
That is the property that stops a frontend bouncing between "loading" and
|
||||
"loaded" for one id — browse(root_id) must be browse(None) in shape.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, _states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
scheduled = _wire_the_browser(entity)
|
||||
|
||||
first = await _browse_over_the_real_handler(entity, scheduled)
|
||||
root_id = first["media_content_id"]
|
||||
|
||||
for content_type, content_id in (
|
||||
(None, None),
|
||||
("apps", root_id),
|
||||
("apps", "apps"),
|
||||
("app", "ar.tvplayer.tv"),
|
||||
):
|
||||
repeat = await _browse_over_the_real_handler(
|
||||
entity, scheduled, content_id=content_id, content_type=content_type
|
||||
)
|
||||
|
||||
assert repeat == first
|
||||
+7
-1
@@ -79,14 +79,20 @@ class _SeedHass(SimpleNamespace):
|
||||
config_entries = _SeedEntriesStub()
|
||||
config_entries.updating_entry_calls = []
|
||||
self._services = {("nvidia_shield", "adb_command"): None}
|
||||
#: t14: per-service response support the registration declared.
|
||||
self.response_support: dict[tuple, object] = {}
|
||||
super().__init__(
|
||||
config_entries=config_entries,
|
||||
scheduled_tasks=[],
|
||||
services=self,
|
||||
)
|
||||
|
||||
def async_register(self, domain, service, func, schema=None):
|
||||
def async_register(
|
||||
self, domain, service, func, schema=None, supports_response=None
|
||||
):
|
||||
self._services[(domain, service)] = (func, schema)
|
||||
#: t14: HA is told whether the action answers with a response.
|
||||
self.response_support[(domain, service)] = supports_response
|
||||
|
||||
def has_service(self, domain, service):
|
||||
return (domain, service) in self._services
|
||||
|
||||
+397
-44
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import entity_stublib # noqa: E402
|
||||
@@ -37,7 +38,10 @@ def scoped_entity_stubs():
|
||||
from nsh.connection import ShieldConnectionManager # noqa: E402
|
||||
from nsh.const import ( # noqa: E402
|
||||
APP_ICON_FALLBACKS,
|
||||
APP_LABEL_EXIT_MARKER,
|
||||
APP_LABEL_GREP_SHAPES,
|
||||
APP_LIST_TTL,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_APP_NAME_MAP,
|
||||
)
|
||||
from nsh.ha_adb_common import ( # noqa: E402
|
||||
@@ -50,16 +54,35 @@ from nsh.ha_adb_common import ( # noqa: E402
|
||||
|
||||
# ── realistic device output ────────────────────────────────────────────────
|
||||
|
||||
#: `pm list packages -3` on a Shield with a normal app set. Wrapped the way a
|
||||
#: real adb shell answers (a bare list, no header).
|
||||
#: `pm list packages -3` on the user's Shield (t66). These are the apps the
|
||||
#: report listed, which is also why they are the curated table's rows: on THIS
|
||||
#: device the firmware names none of them, so the table — not the device — is where
|
||||
#: the names come from. `org.xbmc.kodi` and `com.example.handmade` are kept as the
|
||||
#: packages NOTHING can name (no device label, no curated row), so the fall-through
|
||||
#: to "" is pinned on the same list.
|
||||
USER_PACKAGES = (
|
||||
"package:ar.tvplayer.tv\n"
|
||||
"package:be.digitalia.mediasession2mqtt\n"
|
||||
"package:com.amazon.amazonvideo.livingroom\n"
|
||||
"package:com.esaba.downloader\n"
|
||||
"package:com.github.damontecres.wholphin\n"
|
||||
"package:com.netflix.ninja\n"
|
||||
"package:com.google.android.sambadocumentsprovider\n"
|
||||
"package:com.google.android.speech.pumpkin\n"
|
||||
"package:com.google.android.tts\n"
|
||||
"package:com.google.android.tvlauncher\n"
|
||||
"package:com.google.android.youtube.tv\n"
|
||||
"package:com.hyperion.grabber\n"
|
||||
"package:com.lonelycatgames.Xplore\n"
|
||||
"package:com.nendo.argosy\n"
|
||||
"package:com.netflix.ninja\n"
|
||||
"package:com.plexapp.mediaserver.smb\n"
|
||||
"package:com.retroarch\n"
|
||||
"package:com.spocky.projengmenu\n"
|
||||
"package:dev.jdtech.jellyfin\n"
|
||||
"package:dev.trooped.tvquickbars\n"
|
||||
"package:flar2.homebutton\n"
|
||||
"package:tv.pluto.android\n"
|
||||
"package:org.xbmc.kodi\n"
|
||||
"package:com.plexapp.android\n"
|
||||
"package:com.spotify.tv.android\n"
|
||||
"package:com.example.handmade\n"
|
||||
)
|
||||
|
||||
@@ -88,18 +111,133 @@ DUMP_LABEL_LESS = (
|
||||
" codePath=/data/app/com.example.handmade\n"
|
||||
)
|
||||
|
||||
#: The REAL device shape, trimmed: `pm dump com.netflix.ninja` on the user's Shield
|
||||
#: (captured 2026-10-12; the observed structure is recorded in
|
||||
#: ``docs/INTENTS_PLAN.md`` §1). It is the evidence behind this work — the raw
|
||||
#: ~2241-line capture text is NOT in the repository, so this is a faithful
|
||||
#: reproduction of the recorded structure, not the capture itself.
|
||||
#:
|
||||
#: WHAT IS REPRODUCED: the whole-dumpsys envelope (`DUMP OF SERVICE package:`,
|
||||
#: then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`), the
|
||||
#: package section opening with `Activity Resolver Table:` followed by the
|
||||
#: receiver/service tables, Netflix's real entries (`cast:`, `http`/`https` with
|
||||
#: `www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` +
|
||||
#: `LEANBACK_LAUNCHER`) and the capture's own noise (`com.netflix.action.DEBUG_*`,
|
||||
#: `androidx.*`, `com.google.firebase.*`, `ConstraintProxy*`) — and, the point of
|
||||
#: the whole exercise, NO `application-label` line anywhere: grepping the capture
|
||||
#: for `application-label` found nothing.
|
||||
#:
|
||||
#: (`nonLocalizedLabel=null`, `labelRes=0x7f0f0d31`) — the capture's record covers
|
||||
#: the resolver tables, not this block. They are here because they are exactly the
|
||||
#: shapes the on-device search must NOT read as a name: `null` is not a name, and a
|
||||
#: resource id is not a name either. The bodies of the four trailing service
|
||||
#: sections are trimmed to one line each (the capture is ~2241 lines; this is ~80).
|
||||
DUMP_NETFLIX_REAL = (
|
||||
"DUMP OF SERVICE package:\n"
|
||||
"Activity Resolver Table:\n"
|
||||
" Full MIME Types:\n"
|
||||
" video/*: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a11\n"
|
||||
" Action: \"android.intent.action.VIEW\"\n"
|
||||
" Category: \"android.intent.category.DEFAULT\"\n"
|
||||
" Category: \"android.intent.category.BROWSABLE\"\n"
|
||||
" Scheme: \"https\"\n"
|
||||
" Authority: \"www.netflix.com\": -1\n"
|
||||
" Path: \"PatternMatcher{LITERAL,/title}\"\n"
|
||||
" Scheme: \"http\"\n"
|
||||
" Authority: \"www.netflix.com\": -1\n"
|
||||
" nflx: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a12\n"
|
||||
" Action: \"android.intent.action.VIEW\"\n"
|
||||
" Category: \"android.intent.category.DEFAULT\"\n"
|
||||
" Scheme: \"nflx\"\n"
|
||||
" netflix: 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a13\n"
|
||||
" Action: \"android.intent.action.VIEW\"\n"
|
||||
" Scheme: \"netflix\"\n"
|
||||
" Non-Data Actions:\n"
|
||||
" android.intent.action.MAIN:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a14\n"
|
||||
" Action: \"android.intent.action.MAIN\"\n"
|
||||
" Category: \"android.intent.category.LEANBACK_LAUNCHER\"\n"
|
||||
" cast:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/.ui.MainActivity filter 7f0f0a15\n"
|
||||
" Action: \"android.intent.action.VIEW\"\n"
|
||||
" Scheme: \"cast\"\n"
|
||||
" com.netflix.action.DEBUG_FORCE_EXIT:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/.DebugReceiver filter 7f0f0a16\n"
|
||||
" Action: \"com.netflix.action.DEBUG_FORCE_EXIT\"\n"
|
||||
"Receiver Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
" androidx.work.DiagnosticsReportReceiver:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/androidx.work.impl.diagnostics.DiagnosticsReceiver\n"
|
||||
" com.google.firebase.MESSAGING_EVENT:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/com.google.firebase.messaging.FirebaseMessagingService\n"
|
||||
" com.netflix.action.DEBUG_PUSH_NOTIFICATION:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/.push.DebugPushReceiver\n"
|
||||
"Service Resolver Table:\n"
|
||||
" Non-Data Actions:\n"
|
||||
" com.google.firebase.INSTANCE_ID_EVENT:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/com.google.firebase.iid.FirebaseInstanceIdService\n"
|
||||
"Provider Resolver Table:\n"
|
||||
" Authorities:\n"
|
||||
" com.netflix.ninja.androidx-startup:\n"
|
||||
" 7f0f4c12 com.netflix.ninja/androidx.startup.InitializationProvider\n"
|
||||
"Permissions:\n"
|
||||
" Permission [com.netflix.ninja.C2D_MESSAGE]:\n"
|
||||
" sourcePackage=com.netflix.ninja\n"
|
||||
"Packages:\n"
|
||||
" Package [com.netflix.ninja] (7f1a2b3c):\n"
|
||||
" userId=10123\n"
|
||||
" pkg=Package{7f1a2b3c com.netflix.ninja}\n"
|
||||
# the two INFERRED lines (see the note above): the shapes the search must skip
|
||||
" nonLocalizedLabel=null\n"
|
||||
" labelRes=0x7f0f0d31\n"
|
||||
" versionCode=61102 minSdk=21 targetSdk=29\n"
|
||||
" versionName=6.11.2 build 3413\n"
|
||||
" codePath=/data/app/com.netflix.ninja-1/base.apk\n"
|
||||
" ConstraintProxy.BatteryBroadcastReceiver=\n"
|
||||
" com.netflix.ninja/androidx.work.impl.constraints.trackers."
|
||||
"ConstraintProxy$BatteryChargingProxy\n"
|
||||
" Activity Resolver Table: (nothing else listed for this package)\n"
|
||||
"DUMP OF SERVICE activity:\n"
|
||||
" ACTIVITY MANAGER PENDING INTENTS (dumpsys activity pending)\n"
|
||||
" (nothing)\n"
|
||||
"DUMP OF SERVICE meminfo:\n"
|
||||
" Applications Memory Usage (in Kilobytes):\n"
|
||||
"DUMP OF SERVICE procstats:\n"
|
||||
" Process Stats:\n"
|
||||
"DUMP OF SERVICE usagestats:\n"
|
||||
" In-memory rollup:\n"
|
||||
"DUMP OF SERVICE batterystats:\n"
|
||||
" Battery Stats:\n"
|
||||
)
|
||||
|
||||
|
||||
def _on_device_grep(dump: str) -> str:
|
||||
"""What the WIRE carries once the fetch greps on the device (t66).
|
||||
|
||||
The real command is ``pm dump <pkg> | grep -E <shapes> | head -40``, so the
|
||||
2241-line dump never crosses ADB: only the matching lines do, plus the exit
|
||||
marker the manager relies on. A fake that returned the whole dump would only
|
||||
prove that the PARSER tolerates it — not that the manager never had to.
|
||||
"""
|
||||
shapes = APP_LABEL_GREP_SHAPES.split("|")
|
||||
hits = [line for line in dump.splitlines() if any(s in line for s in shapes)]
|
||||
return "\n".join(hits[:40]) + f"\n{APP_LABEL_EXIT_MARKER}0\n"
|
||||
|
||||
|
||||
class _DiscoveryPoller:
|
||||
"""A poller whose shell answers the discovery commands from fixtures."""
|
||||
|
||||
def __init__(self, *, packages=USER_PACKAGES, launcher=LAUNCHER_RESOLVED,
|
||||
dumps=None, failure=False):
|
||||
dumps=None, failure=False, on_device_grep=False):
|
||||
self.connected = True
|
||||
self.connected_override = True
|
||||
self.packages = packages
|
||||
self.launcher = launcher
|
||||
self.dumps = dict(dumps or {})
|
||||
self.failure = failure
|
||||
#: t66: True = the fixture values are RAW device dumps and the fake applies
|
||||
#: the on-device grep before answering, like the real command does.
|
||||
self.on_device_grep = on_device_grep
|
||||
self.shell_calls: list[str] = []
|
||||
|
||||
@property
|
||||
@@ -119,28 +257,34 @@ class _DiscoveryPoller:
|
||||
|
||||
for package, dump in self.dumps.items():
|
||||
if shlex.quote(package) in cmd or package in cmd:
|
||||
return dump
|
||||
return _on_device_grep(dump) if self.on_device_grep else dump
|
||||
return "__NSH_EXIT:0\n" # ran, no label
|
||||
return None
|
||||
|
||||
|
||||
def _manager(poller):
|
||||
return ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||
|
||||
def _entry(*, adb_enabled=True, **options):
|
||||
"""A realistic entry: the ADB option is ON unless a test says otherwise (t23)."""
|
||||
return SimpleNamespace(
|
||||
options={CONF_ADB_ENABLED: adb_enabled, **options},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
def _manager(poller, *, adb_enabled=True):
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
|
||||
)
|
||||
|
||||
|
||||
class TestPackageListParsing:
|
||||
"""`pm list packages -3`."""
|
||||
|
||||
def test_real_output_parses_one_package_per_line(self):
|
||||
# the order of `pm list packages -3` is preserved, one id per line
|
||||
assert parse_package_list(USER_PACKAGES) == [
|
||||
"ar.tvplayer.tv",
|
||||
"com.github.damontecres.wholphin",
|
||||
"com.netflix.ninja",
|
||||
"com.google.android.youtube.tv",
|
||||
"org.xbmc.kodi",
|
||||
"com.plexapp.android",
|
||||
"com.spotify.tv.android",
|
||||
"com.example.handmade",
|
||||
line.removeprefix("package:") for line in USER_PACKAGES.splitlines()
|
||||
]
|
||||
|
||||
def test_noise_and_duplicates_are_tolerated(self):
|
||||
@@ -192,10 +336,19 @@ class TestInstalledPackagesMerge:
|
||||
"""User apps plus the active launcher, de-duplicated."""
|
||||
|
||||
def test_launcher_is_added_last(self):
|
||||
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
|
||||
# t66: the user's own list contains the launcher, so the append-last
|
||||
# property is pinned on a list that does NOT already have it
|
||||
user_only = USER_PACKAGES.replace("package:com.google.android.tvlauncher\n", "")
|
||||
packages = installed_packages(user_only, LAUNCHER_RESOLVED)
|
||||
assert packages[-1] == "com.google.android.tvlauncher"
|
||||
assert packages.count("com.google.android.tvlauncher") == 1
|
||||
assert packages[0] == "ar.tvplayer.tv"
|
||||
|
||||
def test_a_launcher_already_listed_keeps_its_place(self):
|
||||
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
|
||||
assert packages.count("com.google.android.tvlauncher") == 1
|
||||
assert packages == parse_package_list(USER_PACKAGES)
|
||||
|
||||
def test_a_third_party_launcher_is_not_duplicated(self):
|
||||
user = "package:com.example.launcher\npackage:com.a\n"
|
||||
launcher = "com.example.launcher/.MainActivity"
|
||||
@@ -239,17 +392,22 @@ class TestDiscoverySweep:
|
||||
by_pkg = {row["package"]: row for row in rows}
|
||||
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # from the dump
|
||||
assert by_pkg["ar.tvplayer.tv"]["icon"] == "mdi:television-classic"
|
||||
# a label-less app keeps its discovered name (the package id) and is
|
||||
# still a row — it must not vanish from the table
|
||||
assert by_pkg["com.example.handmade"]["name"] == "com.example.handmade"
|
||||
# and every row's name/icon is a string
|
||||
# t66: nothing can name `com.example.handmade` (the dump carries no label
|
||||
# and it is not in the curated table), so its name is "" — the id is
|
||||
# already published once in `package`, and a name that repeats it is the
|
||||
# `pkg=pkg` row the user reported from the apps editor. The row itself is
|
||||
# still offered: it must not vanish from the table.
|
||||
assert by_pkg["com.example.handmade"]["name"] == ""
|
||||
assert by_pkg["com.example.handmade"]["package"] == "com.example.handmade"
|
||||
# every row has a string name ("" when nothing names it) and a real icon
|
||||
for row in rows:
|
||||
assert isinstance(row["name"], str) and row["name"]
|
||||
assert isinstance(row["name"], str)
|
||||
assert isinstance(row["icon"], str) and row["icon"]
|
||||
|
||||
async def test_the_live_bug_the_discovered_list_is_a_superset(self):
|
||||
"""The complaint in one assertion: every app the user has, plus the
|
||||
curated four, is offered — not just the curated four."""
|
||||
"""The complaint in one assertion: every app the user has is offered, and
|
||||
each one is NAMED — on this device none of them is named by the firmware.
|
||||
"""
|
||||
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
@@ -257,21 +415,19 @@ class TestDiscoverySweep:
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
packages = {row["package"] for row in rows}
|
||||
by_pkg = {row["package"]: row for row in rows}
|
||||
|
||||
# the curated list the integration used to fall back to
|
||||
# the curated table is exactly the app set the report listed
|
||||
assert set(DEFAULT_APP_DIRECTORY) <= packages
|
||||
# ...and the apps it missed, which is what the user reported
|
||||
for package in (
|
||||
"ar.tvplayer.tv", # TiviMate
|
||||
"com.github.damontecres.wholphin", # Wholphin
|
||||
"com.netflix.ninja", # Netflix
|
||||
"com.google.android.youtube.tv", # YouTube
|
||||
"org.xbmc.kodi",
|
||||
"com.plexapp.android",
|
||||
"com.spotify.tv.android",
|
||||
"com.example.handmade",
|
||||
):
|
||||
assert package in packages, package
|
||||
# ...and every one of those apps reads by its NAME, from the curated map,
|
||||
# with the device naming nothing (t66)
|
||||
for package, name in DEFAULT_APP_DIRECTORY.items():
|
||||
assert name
|
||||
assert by_pkg[package]["name"], package
|
||||
if package != "ar.tvplayer.tv": # this one's dump DOES carry a label
|
||||
assert by_pkg[package]["name"] == name, package
|
||||
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # the device wins here
|
||||
assert by_pkg["org.xbmc.kodi"]["name"] == "" # known to nobody
|
||||
assert "com.google.android.tvlauncher" in packages # + the launcher
|
||||
# a system app the device never lists as -3 is NOT invented here
|
||||
assert "com.android.systemui" not in packages
|
||||
@@ -343,7 +499,7 @@ class TestDiscoverySweep:
|
||||
now = [1000.0]
|
||||
poller = _DiscoveryPoller()
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||
hass=None, entry=_entry(), poller=poller, clock=lambda: now[0]
|
||||
)
|
||||
await manager.async_refresh_installed_apps()
|
||||
assert manager.installed_apps()
|
||||
@@ -400,7 +556,7 @@ class TestDiscoveryDegradesGracefully:
|
||||
"""
|
||||
|
||||
async def test_no_poller_is_an_empty_list(self):
|
||||
manager = ShieldConnectionManager(hass=None, entry=None, poller=None)
|
||||
manager = ShieldConnectionManager(hass=None, entry=_entry(), poller=None)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
@@ -425,7 +581,7 @@ class TestDiscoveryDegradesGracefully:
|
||||
now = [1000.0]
|
||||
poller = _DiscoveryPoller(failure=True)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||
hass=None, entry=_entry(), poller=poller, clock=lambda: now[0]
|
||||
)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
@@ -504,6 +660,13 @@ class TestDiscoveryUsesTheCachedLabelPath:
|
||||
dumps = [c for c in poller.shell_calls if "pm dump" in c]
|
||||
assert dumps # the first sweep labelled everything it could
|
||||
assert len(dumps) >= 5 # ...and it did dump for the unlabelled ones
|
||||
# t66: and then it STOPPED. On this device 5 probes in a row find no label,
|
||||
# so the sweep does not spend one shell per package — the packaged-in
|
||||
# in-flight window is the only slack above the probe limit.
|
||||
from nsh.const import APP_LABEL_PROBE_LIMIT, APP_SWEEP_CONCURRENCY
|
||||
|
||||
assert len(dumps) <= APP_LABEL_PROBE_LIMIT + APP_SWEEP_CONCURRENCY + 1
|
||||
assert len(dumps) < len(USER_PACKAGES.splitlines())
|
||||
|
||||
# the cache now answers for EVERY package, so a forced re-sweep lists
|
||||
# again but dumps nothing at all (the 24 h label cache, not the table TTL)
|
||||
@@ -514,11 +677,21 @@ class TestDiscoveryUsesTheCachedLabelPath:
|
||||
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||
by_pkg = {row["package"]: row for row in rows}
|
||||
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate"
|
||||
assert by_pkg["org.xbmc.kodi"]["name"] == "org.xbmc.kodi"
|
||||
# nothing names kodi (it is in no curated row, and the memo'd device says
|
||||
# "no label"): the row shows the id ONCE, in `package`, and name ""
|
||||
assert by_pkg["org.xbmc.kodi"]["name"] == ""
|
||||
|
||||
async def test_a_failed_label_is_retried_by_the_next_sweep(self):
|
||||
"""UNIT 0 semantics at scale: a transient failure is not permanent."""
|
||||
poller = _DiscoveryPoller(dumps={"org.xbmc.kodi": "application-label: 'Kodi'"})
|
||||
# a SMALL app set, with the failing package FIRST: it is the first probe the
|
||||
# sweep makes, so its failure is recorded before the device-level memo can
|
||||
# form — which is exactly the state the retry guarantee is about
|
||||
packages = "package:org.xbmc.kodi\n" + "".join(
|
||||
f"package:com.example.app{i}\n" for i in range(6)
|
||||
)
|
||||
poller = _DiscoveryPoller(
|
||||
packages=packages, dumps={"org.xbmc.kodi": "application-label: 'Kodi'"}
|
||||
)
|
||||
manager = _manager(poller)
|
||||
real_shell = poller.shell
|
||||
state = {"fail_kodi": True}
|
||||
@@ -532,10 +705,19 @@ class TestDiscoveryUsesTheCachedLabelPath:
|
||||
poller.shell = _flaky
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
by_pkg = {row["package"]: row for row in rows}
|
||||
assert by_pkg["org.xbmc.kodi"]["name"] == "org.xbmc.kodi" # fell back
|
||||
assert by_pkg["org.xbmc.kodi"]["name"] == "" # fell back (nothing names it)
|
||||
assert manager.app_label("org.xbmc.kodi") is None # NOT cached
|
||||
|
||||
# t66: the device-level memo is set by now (5 label-less answers), so the
|
||||
# sweep will not fan out again — but kodi's FAILED probe is still owed its
|
||||
# retry, and the app on screen is what drives it (t57 UNIT 0 survives).
|
||||
assert manager.app_labels_absent is True
|
||||
assert manager._probe_still_owed("org.xbmc.kodi") is True
|
||||
assert await manager._dump_label("org.xbmc.kodi", on_demand=True) is None
|
||||
|
||||
state["fail_kodi"] = False
|
||||
await manager._fetch_app_label("org.xbmc.kodi") # the on-demand retry
|
||||
assert manager.app_label("org.xbmc.kodi") == "Kodi"
|
||||
manager._installed_apps = None
|
||||
rows2 = await manager.async_refresh_installed_apps()
|
||||
by_pkg2 = {row["package"]: row for row in rows2}
|
||||
@@ -667,3 +849,174 @@ def test_importing_the_discovery_module_needs_no_home_assistant():
|
||||
|
||||
assert module.__doc__ and "pm list packages -3" in module.__doc__
|
||||
assert not [n for n in dir(module) if n.startswith("hass")]
|
||||
|
||||
|
||||
class TestTheRealDeviceShape:
|
||||
"""t66: the user's own `pm dump com.netflix.ninja` shape, and what it implies.
|
||||
|
||||
Three properties are pinned against the fixture, because each one was a live
|
||||
defect: the capture carries no label the parser can mistake for a name; the
|
||||
fetch must not truncate the dump nor keep shelling every package once the
|
||||
device has said it has no labels; and the name the user then SEES comes from
|
||||
the curated table.
|
||||
"""
|
||||
|
||||
def test_no_label_is_extracted_from_the_whole_capture(self):
|
||||
from nsh.ha_adb_common import parse_app_label, parse_application_label
|
||||
|
||||
# the capture's shape, in one place: a whole dumpsys (not a stub), whose
|
||||
# package section is a resolver table rather than a manifest block
|
||||
assert DUMP_NETFLIX_REAL.startswith("DUMP OF SERVICE package:\n")
|
||||
assert "Activity Resolver Table:" in DUMP_NETFLIX_REAL
|
||||
assert "DUMP OF SERVICE batterystats:" in DUMP_NETFLIX_REAL
|
||||
assert len(DUMP_NETFLIX_REAL.splitlines()) > 60
|
||||
|
||||
# (a) NOTHING in it is a name — this is the "no label" the report saw, and
|
||||
# it must be a parse result, not a truncation artefact
|
||||
assert "application-label" not in DUMP_NETFLIX_REAL
|
||||
assert parse_application_label(DUMP_NETFLIX_REAL) is None
|
||||
assert parse_app_label(DUMP_NETFLIX_REAL) is None
|
||||
|
||||
def test_the_inferred_shapes_are_not_mistaken_for_names(self):
|
||||
"""The two ApplicationInfo lines the capture's record does not cover."""
|
||||
from nsh.ha_adb_common import parse_label_res, parse_non_localized_label
|
||||
|
||||
assert parse_non_localized_label(DUMP_NETFLIX_REAL) is None # =null
|
||||
assert parse_label_res(DUMP_NETFLIX_REAL) is None # a resource id
|
||||
assert parse_non_localized_label("nonLocalizedLabel=Netflix") == "Netflix"
|
||||
assert parse_label_res('labelRes="Netflix"') == "Netflix"
|
||||
assert parse_label_res("labelRes=0x7f0f0d31") is None
|
||||
|
||||
async def test_the_search_runs_on_the_device_and_the_dump_is_never_truncated(self):
|
||||
poller = _DiscoveryPoller(
|
||||
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
# nothing names this package, and the probe still RAN: a definitive answer
|
||||
assert await manager._dump_label("com.netflix.ninja") == ""
|
||||
|
||||
commands = [c for c in poller.shell_calls if "pm dump" in c]
|
||||
assert len(commands) == 1
|
||||
assert "head -300" not in commands[0] # the defect: it cut the dump short
|
||||
assert "grep -E" in commands[0] # the fix: filtered before the wire
|
||||
for shape in ("application-label", "nonLocalizedLabel", "labelRes"):
|
||||
assert shape in commands[0], shape
|
||||
|
||||
async def test_one_shell_per_package_then_the_device_is_believed(self):
|
||||
poller = _DiscoveryPoller(
|
||||
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
dumps = [c for c in poller.shell_calls if "pm dump" in c]
|
||||
|
||||
# (b) the sweep did NOT spend one shell per package: on this device 5
|
||||
# label-less answers in a row are the device-level conclusion, and only the
|
||||
# packages already in flight can add to that count
|
||||
from nsh.const import APP_LABEL_PROBE_LIMIT, APP_SWEEP_CONCURRENCY
|
||||
|
||||
assert len(dumps) <= APP_LABEL_PROBE_LIMIT + APP_SWEEP_CONCURRENCY + 1
|
||||
assert len(dumps) < len(USER_PACKAGES.splitlines())
|
||||
assert manager.app_labels_absent is True
|
||||
assert "Netflix" in {row["name"] for row in rows}
|
||||
|
||||
# a forced re-sweep costs ZERO further probes: every package is cached
|
||||
manager._installed_apps = None
|
||||
await manager.async_refresh_installed_apps(force=True)
|
||||
assert [c for c in poller.shell_calls if "pm dump" in c] == dumps
|
||||
|
||||
async def test_the_curated_name_is_what_surfaces(self):
|
||||
"""(c) the name the user sees, on every surface that shows one."""
|
||||
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||
from nsh.ha_adb_common import resolve_package_name
|
||||
from nsh.media_player import resolve_app_name
|
||||
|
||||
poller = _DiscoveryPoller(
|
||||
dumps={"com.netflix.ninja": DUMP_NETFLIX_REAL}, on_device_grep=True
|
||||
)
|
||||
manager = _manager(poller)
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
by_pkg = {row["package"]: row for row in rows}
|
||||
|
||||
# the discovery table
|
||||
assert by_pkg["com.netflix.ninja"]["name"] == "Netflix"
|
||||
|
||||
# the chain the entities call: no user map, and the device named nothing
|
||||
assert resolve_app_name("com.netflix.ninja", {}, manager.app_label) == "Netflix"
|
||||
assert (
|
||||
resolve_package_name(
|
||||
"com.netflix.ninja", device_label="", curated=DEFAULT_APP_DIRECTORY
|
||||
)
|
||||
== "Netflix"
|
||||
)
|
||||
|
||||
# ...and the installed-apps sensor publishes the same table
|
||||
sensor_module = load_entity_modules()["sensor"]
|
||||
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
|
||||
sensor_module.NvidiaShieldAppsSensor
|
||||
)
|
||||
entry = type("E", (), {})()
|
||||
entry.entry_id = "e1"
|
||||
entry.options = {}
|
||||
entry.runtime_data = manager
|
||||
entry.title = "mdarcy"
|
||||
sensor.hass = entity_stublib.FakeHass()
|
||||
sensor._entry = entry
|
||||
assert any(
|
||||
app["package"] == "com.netflix.ninja" and app["name"] == "Netflix"
|
||||
for app in sensor.extra_state_attributes["installed_apps"]
|
||||
)
|
||||
|
||||
|
||||
def test_the_name_chain_is_one_order_everywhere():
|
||||
"""user map > device label > curated > raw id, in ONE pure function (t66)."""
|
||||
from nsh.ha_adb_common import resolve_package_name
|
||||
|
||||
curated = {"com.curated": "Curated", "com.both": "Curated Both"}
|
||||
user = {"com.mapped": "Mapped", "com.both": "Mapped Both"}
|
||||
|
||||
assert resolve_package_name("com.mapped", user_map=user, curated=curated) == "Mapped"
|
||||
assert resolve_package_name("com.curated", device_label="From Device") == "From Device"
|
||||
assert resolve_package_name("com.curated", curated=curated) == "Curated"
|
||||
assert resolve_package_name("com.both", user_map=user, curated=curated) == "Mapped Both"
|
||||
# the device label sits BELOW the user map and ABOVE the curated table
|
||||
assert (
|
||||
resolve_package_name("com.both", user_map={}, device_label="D", curated=curated)
|
||||
== "D"
|
||||
)
|
||||
# the id is shown ONCE, and an empty package has no name at all
|
||||
assert resolve_package_name("com.nobody", curated=curated) == "com.nobody"
|
||||
assert resolve_package_name("", curated=curated) is None
|
||||
assert resolve_package_name(None) is None
|
||||
# a blank device label is not a name (it is what a memo'd device returns)
|
||||
assert resolve_package_name("com.curated", device_label="", curated=curated) == "Curated"
|
||||
assert resolve_package_name("com.curated", device_label=" ", curated=curated) == "Curated"
|
||||
|
||||
|
||||
def test_the_device_label_lookup_is_lazy():
|
||||
"""A mapped app must not cost a device probe (t66).
|
||||
|
||||
``media_player.resolve_app_name`` passes its ``app_label`` accessor, which
|
||||
ENQUEUES an on-demand fetch on a miss: calling it for an app the user already
|
||||
named would spend a shell to learn something nobody reads.
|
||||
"""
|
||||
from nsh.ha_adb_common import resolve_package_name
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _device_label(package):
|
||||
calls.append(package)
|
||||
return None
|
||||
|
||||
assert (
|
||||
resolve_package_name(
|
||||
"com.mapped", user_map={"com.mapped": "Mapped"}, device_label=_device_label
|
||||
)
|
||||
== "Mapped"
|
||||
)
|
||||
assert calls == [] # the device was never asked
|
||||
|
||||
assert resolve_package_name("com.other", device_label=_device_label) == "com.other"
|
||||
assert calls == ["com.other"] # ...and it IS asked when the map misses
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -57,7 +57,13 @@ class TestSourceList:
|
||||
def test_it_falls_back_to_the_curated_directory(self, modules):
|
||||
player, _served = _player(modules) # no map configured
|
||||
|
||||
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
|
||||
# t66: the curated table grew to cover the apps the report listed, and the
|
||||
# dropdown is the same table (so it is asserted against the table itself,
|
||||
# not a copy that would drift)
|
||||
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||
|
||||
assert player.source_list == list(DEFAULT_APP_DIRECTORY.values())
|
||||
assert "Netflix" in player.source_list # a name the user recognises
|
||||
|
||||
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
|
||||
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
|
||||
@@ -464,8 +470,25 @@ class TestCaseInsensitiveNames:
|
||||
assert player.source == " TiviMate " # this surface shows it as typed
|
||||
assert player.source not in player.source_list
|
||||
|
||||
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
|
||||
"""Blanks are not displayed as nothing — the chain continues past them."""
|
||||
def test_a_blank_map_value_falls_through_the_chain(self, modules):
|
||||
"""Blanks are not displayed as nothing — the chain continues past them (t66).
|
||||
|
||||
Past a blank map value comes the device label, then the curated table, then
|
||||
the raw id: `ar.tvplayer.tv` is curated, so the dropdown row is its NAME.
|
||||
The raw-id tail of the same chain is pinned in the test below.
|
||||
"""
|
||||
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
|
||||
|
||||
assert player.source == TIVIMATE
|
||||
assert player.source == "TiviMate"
|
||||
|
||||
def test_a_package_nothing_names_stays_raw(self, modules):
|
||||
"""The last tier: no map value, no device label, no curated row → the id.
|
||||
|
||||
It is shown ONCE (the id itself), never as a `pkg=pkg` pair.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules, app_map={"com.example.unknown": ""}, foreground="com.example.unknown"
|
||||
)
|
||||
|
||||
assert player.source == "com.example.unknown"
|
||||
assert player.source_list == [] # a name is the ticker's raw id
|
||||
@@ -0,0 +1,175 @@
|
||||
"""tests/test_now_playing_publication.py — which entity states may carry a reading (t17/O2).
|
||||
|
||||
THE OBSERVATION THIS CLOSES. The batch review read `extra_state_attributes` and
|
||||
argued the now-playing row publishes "the entity state verbatim", so a dashboard
|
||||
could see `now_playing_state: on`, while the README documents only `playing`,
|
||||
`paused`, `buffering` and `idle`. t10's author had reported the opposite (an
|
||||
`on` or `off` device publishes no row at all). Both accounts were about a
|
||||
different thing, and this file settles it BY EXECUTION:
|
||||
|
||||
* the t10 account is true for the NATURAL ladder — `on` means no source reported
|
||||
a power or playback fact, and a reading only exists when a tier answered, so
|
||||
the two cannot meet in practice;
|
||||
* the review was right about the RULE, which was "publish unless the state is
|
||||
`off`" — an unenforced assumption, and the probe below shows it publishing
|
||||
`now_playing_state: on` (and `standby`) the moment a reading exists beside that
|
||||
state. `standby` is not even in the README, and nothing in the code stopped it.
|
||||
|
||||
WHAT WAS CHANGED. The publication rule now names the four values a reading may
|
||||
carry (`NOW_PLAYING_STATE_VALUES`) and drops the reading when the entity state is
|
||||
anything else, exactly as a state of `off` always did. The README says the same
|
||||
thing in words. The two tests here walk EVERY ``MediaPlayerState`` member and
|
||||
compare the README against the code constant, so neither can drift again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import entity_stublib # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
from entity_stublib import MediaPlayerState, load_entity_modules # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + the loaded entity modules (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
yield load_entity_modules()
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
#: The states the README documents, read from the file itself (never from the
|
||||
#: constant under test) so a code change cannot silently rewrite the pin.
|
||||
def _documented_states() -> set[str]:
|
||||
text = README.read_text(encoding="utf-8")
|
||||
row = re.search(r"^\| `now_playing_state` \|(.*)\|$", text, re.M)
|
||||
assert row, "the README's now_playing_state table row is gone"
|
||||
return {value for value in re.findall(r"`([a-z_]+)`", row.group(1)) if value != "on"}
|
||||
|
||||
|
||||
def _player_for_state(modules, member):
|
||||
"""A player whose ladder answers `member` while a reading is available.
|
||||
|
||||
The state is forced by patching the ``state`` property, which is the only way
|
||||
to ask "what would be published IF the ladder ever answered this?" — the
|
||||
question the review raised. A reading is genuine: the foreground tier answers
|
||||
it through the same code path production uses (``_now_playing``).
|
||||
"""
|
||||
from test_command_routing import TIVIMATE_PACKAGE, _now_playing_attrs, _state_player
|
||||
|
||||
player, _manager = _state_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||
player._last_channel = "101"
|
||||
media_player_cls = type(player)
|
||||
original = media_player_cls.__dict__.get("state")
|
||||
media_player_cls.state = property(lambda self, forced=member: forced)
|
||||
try:
|
||||
reading = player._now_playing()
|
||||
attrs = _now_playing_attrs(player)
|
||||
finally:
|
||||
if original is None: # pragma: no cover - the class always declares it
|
||||
del media_player_cls.state
|
||||
else:
|
||||
media_player_cls.state = original
|
||||
return reading, attrs
|
||||
|
||||
|
||||
class TestThePublicationRule:
|
||||
"""One reading in, one row out — for EVERY member the enum has."""
|
||||
|
||||
def test_the_documented_four_publish_the_reading(self, scoped_entity_stubs):
|
||||
modules = scoped_entity_stubs
|
||||
documented = {
|
||||
MediaPlayerState.PLAYING: "playing",
|
||||
MediaPlayerState.PAUSED: "paused",
|
||||
MediaPlayerState.BUFFERING: "buffering",
|
||||
MediaPlayerState.IDLE: "idle",
|
||||
}
|
||||
|
||||
for member, value in documented.items():
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
|
||||
assert reading, f"{value}: the fixture must have a reading to publish"
|
||||
assert attrs["now_playing_state"] == value
|
||||
assert attrs["now_playing_title"] == "101"
|
||||
|
||||
def test_a_state_outside_the_documented_four_publishes_nothing(self, scoped_entity_stubs):
|
||||
"""The review's case, executed: `on` publishes no row — by construction now."""
|
||||
modules = scoped_entity_stubs
|
||||
reading_available = {
|
||||
MediaPlayerState.ON: "on",
|
||||
MediaPlayerState.OFF: "off",
|
||||
MediaPlayerState.STANDBY: "standby",
|
||||
}
|
||||
|
||||
for member, value in reading_available.items():
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
|
||||
assert reading, f"{value}: the fixture still HAS a reading (that is the point)"
|
||||
assert attrs == {}, f"{value} must publish no now-playing row, got {attrs}"
|
||||
|
||||
def test_every_enum_member_is_accounted_for(self, scoped_entity_stubs):
|
||||
"""The matrix is exhaustive: no state is left untested as the enum grows."""
|
||||
modules = scoped_entity_stubs
|
||||
published = {}
|
||||
for member in MediaPlayerState:
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
published[member.value] = attrs.get("now_playing_state")
|
||||
|
||||
assert set(published) == {member.value for member in MediaPlayerState}
|
||||
for value in ("playing", "paused", "buffering", "idle"):
|
||||
assert published[value] == value
|
||||
for value in ("on", "off", "standby"):
|
||||
assert published[value] is None, f"{value} published a state: {published[value]}"
|
||||
|
||||
def test_the_gate_reads_the_named_constant(self, scoped_entity_stubs):
|
||||
"""The rule is the constant, not a hard-coded pair of states."""
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
assert NOW_PLAYING_STATE_VALUES == ("playing", "paused", "buffering", "idle")
|
||||
assert self._published_for(scoped_entity_stubs, MediaPlayerState.ON) == {}
|
||||
|
||||
@staticmethod
|
||||
def _published_for(modules, member) -> dict:
|
||||
_reading, attrs = _player_for_state(modules, member)
|
||||
return attrs
|
||||
|
||||
|
||||
class TestTheReadmeAgrees:
|
||||
"""The docs and the code say the same four values (t17/O2's anti-drift pin)."""
|
||||
|
||||
def test_the_table_row_lists_exactly_the_constant(self, scoped_entity_stubs):
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
assert _documented_states() == set(NOW_PLAYING_STATE_VALUES)
|
||||
|
||||
def test_the_prose_names_the_same_set_and_excludes_on(self, scoped_entity_stubs):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
paragraph = re.search(
|
||||
r"\*\*`now_playing_state` is one of FOUR values[^*]*\*\*(.*?)\n-", text, re.S
|
||||
)
|
||||
assert paragraph, "the README's now_playing_state paragraph is gone"
|
||||
body = paragraph.group(1)
|
||||
# the sentence that names the set, read apart from the sentence that
|
||||
# excludes `on`/`off` — otherwise the exclusion would look like a member
|
||||
sentence = re.search(r"full set is ([^.]+)\.", body)
|
||||
assert sentence, "the README no longer names the full set"
|
||||
named = set(re.findall(r"`([a-z_]+)`", sentence.group(1)))
|
||||
assert named == set(NOW_PLAYING_STATE_VALUES), named
|
||||
assert "`on`" in body and "not one of them" in text, (
|
||||
"the paragraph must say `on` is excluded"
|
||||
)
|
||||
|
||||
def test_the_off_and_on_prose_still_says_no_rows(self, scoped_entity_stubs):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
|
||||
assert "**A device that reads `off` publishes none of them.**" in text
|
||||
assert "publishes none either" in text
|
||||
@@ -1,33 +1,69 @@
|
||||
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
|
||||
|
||||
Fake client mirrors AndroidTVRemote's surface (callback registration,
|
||||
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
|
||||
Fake client mirrors AndroidTVRemote's surface (callback registration, an
|
||||
``async_connect`` that must run BEFORE ``keep_reconnecting``, a self-owned
|
||||
reconnect task that is a no-op until a session exists, sync ``disconnect``);
|
||||
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
|
||||
against the real library's VolumeInfo TypedDict when the package is present
|
||||
(`uvx --with androidtvremote2 pytest`).
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from nsh.connection import (
|
||||
FIELD_AUDIO_PLAYBACK,
|
||||
FIELD_AWAKE,
|
||||
FIELD_FOREGROUND,
|
||||
FIELD_IS_AVAILABLE,
|
||||
FIELD_IS_ON,
|
||||
FIELD_RUNNING_APPS,
|
||||
FIELD_SESSIONS,
|
||||
FIELD_VOLUME_INFO,
|
||||
PROTOCOL_CONNECTED,
|
||||
ShieldConnectionManager,
|
||||
StateStore,
|
||||
volume_tuple,
|
||||
)
|
||||
from nsh.const import CONF_ADB_ENABLED
|
||||
|
||||
|
||||
class InvalidAuth(Exception): # noqa: N818 - the library's own class name
|
||||
"""The library's InvalidAuth, matched by NAME (the manager never imports it)."""
|
||||
|
||||
|
||||
class CannotConnect(Exception): # noqa: N818 - the library's own class name
|
||||
"""The library's CannotConnect — a retryable transport failure."""
|
||||
|
||||
|
||||
class ConnectionClosed(Exception): # noqa: N818 - the library's own class name
|
||||
"""The library's ConnectionClosed — raised when nothing is connected."""
|
||||
|
||||
|
||||
class FakeProtocolClient:
|
||||
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
|
||||
"""AndroidTVRemote-shaped fake: the library's ORDER, reproduced.
|
||||
|
||||
def __init__(self):
|
||||
t15: the real ``AndroidTVRemote`` keeps ``_remote_message_protocol = None`` until
|
||||
``async_connect()`` succeeds, and ``keep_reconnecting()`` starts a loop whose
|
||||
condition is ``while self._remote_message_protocol:`` — so calling it alone
|
||||
starts a task that returns immediately and NOTHING ever connects (no push, and
|
||||
every send raises ConnectionClosed). The fake reproduces that, and records which
|
||||
of the two calls the manager made, so a regression back to keep_reconnecting-only
|
||||
fails a test instead of silently disabling the transport.
|
||||
"""
|
||||
|
||||
def __init__(self, connect_error: Exception | None = None):
|
||||
self._remote_message_protocol = None # library attribute: None until connected
|
||||
self._connect_error = connect_error
|
||||
self.connect_calls = 0
|
||||
self.keep_reconnecting_calls = 0
|
||||
self.keys_sent: list = []
|
||||
self.apps_launched: list = []
|
||||
self.texts_sent: list = []
|
||||
self._is_on_callbacks: list = []
|
||||
self._is_available_callbacks: list = []
|
||||
self._current_app_callbacks: list = []
|
||||
@@ -66,18 +102,50 @@ class FakeProtocolClient:
|
||||
for cb in list(self._volume_callbacks):
|
||||
cb(info)
|
||||
|
||||
|
||||
# -- lifecycle --
|
||||
def keep_reconnecting(self, invalid_auth_callback=None):
|
||||
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
|
||||
"""Own task like the real library — and, like it, a NO-OP until connected.
|
||||
|
||||
The condition is the library's own: with no session the task runs once,
|
||||
finds nothing to maintain and returns. That is exactly why
|
||||
``async_connect()`` has to come first.
|
||||
"""
|
||||
self.keep_reconnecting_calls += 1
|
||||
self._invalid_auth_callback = invalid_auth_callback
|
||||
loop = asyncio.get_event_loop()
|
||||
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self):
|
||||
while True: # the real task sleeps through its internal backoff
|
||||
while self._remote_message_protocol: # the library's own guard
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# -- connect (t15: the call the manager used to be missing entirely) --
|
||||
async def async_connect(self):
|
||||
"""Connect; raise ``self._connect_error`` when the test wants a failure."""
|
||||
self.connect_calls += 1
|
||||
if self._connect_error is not None:
|
||||
raise self._connect_error
|
||||
self._remote_message_protocol = object() # library sets its RemoteProtocol
|
||||
|
||||
# -- sends (t15: the library raises when nothing is connected) --
|
||||
def send_key_command(self, key, direction="SHORT"):
|
||||
if not self._remote_message_protocol:
|
||||
raise ConnectionClosed("Called send_key_command after disconnect")
|
||||
self.keys_sent.append((key, direction))
|
||||
|
||||
def send_launch_app_command(self, app_link_or_app_id):
|
||||
if not self._remote_message_protocol:
|
||||
raise ConnectionClosed("Called send_launch_app_command after disconnect")
|
||||
self.apps_launched.append(app_link_or_app_id)
|
||||
|
||||
def send_text(self, text):
|
||||
if not self._remote_message_protocol:
|
||||
raise ConnectionClosed("Called send_text after disconnect")
|
||||
self.texts_sent.append(text)
|
||||
|
||||
def fire_invalid_auth(self):
|
||||
if self._invalid_auth_callback is not None:
|
||||
self._invalid_auth_callback()
|
||||
|
||||
def disconnect(self):
|
||||
@@ -86,8 +154,21 @@ class FakeProtocolClient:
|
||||
self._reconnect_task.cancel()
|
||||
|
||||
|
||||
def _adb_entry():
|
||||
"""A realistic entry: its own option says ADB is ON (t23/O1).
|
||||
|
||||
The protocol tests exercise the ADB watchdog paths too (`_watchdog_cycle`),
|
||||
and a manager with no entry means ADB is off — the gate those paths now ask.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
options={CONF_ADB_ENABLED: True}, data={}, title="Shield", entry_id="e1"
|
||||
)
|
||||
|
||||
|
||||
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_adb_entry(), protocol=client, **kwargs
|
||||
)
|
||||
|
||||
|
||||
async def _drain_loop():
|
||||
@@ -95,6 +176,16 @@ async def _drain_loop():
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def _connect(manager, client, tries: int = 40):
|
||||
"""Wait for the manager's background connect task to make its attempt (t15)."""
|
||||
for _ in range(tries):
|
||||
if client.connect_calls:
|
||||
await _drain_loop()
|
||||
return
|
||||
await asyncio.sleep(0.005)
|
||||
raise AssertionError("the manager never attempted to connect the protocol")
|
||||
|
||||
|
||||
class TestPushCallbackWiring:
|
||||
async def test_is_on_and_availability_push_into_store(self):
|
||||
client = FakeProtocolClient()
|
||||
@@ -142,6 +233,7 @@ class TestInvalidAuthHook:
|
||||
client, invalid_auth_callback=lambda: fired.append(True)
|
||||
)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client) # t15: a session first, then pushes/auth
|
||||
|
||||
client.fire_invalid_auth()
|
||||
|
||||
@@ -152,10 +244,202 @@ class TestInvalidAuthHook:
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
client.fire_invalid_auth() # no callback registered — must not raise
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
def _fast_backoff(manager, delay: float = 0.01) -> None:
|
||||
"""Make the connect-retry backoff fast for tests (t15), per manager."""
|
||||
manager._jitter = lambda _delay: delay
|
||||
|
||||
|
||||
class TestInitialConnect:
|
||||
"""t15: the transport must CONNECT, not merely be told to keep reconnecting.
|
||||
|
||||
The shipped bug: `_start_protocol` called `keep_reconnecting()` and nothing in
|
||||
the tree ever called `async_connect()`. The library's reconnect task is
|
||||
`while self._remote_message_protocol:` — None until a first connect — so the
|
||||
task returned immediately: no session, no push, every send raising
|
||||
ConnectionClosed inside a swallowed handler, for every install. These tests pin
|
||||
the order (connect first) and the state it reports.
|
||||
"""
|
||||
|
||||
async def test_connects_before_handing_over_to_keep_reconnecting(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert client.connect_calls == 1
|
||||
assert client.keep_reconnecting_calls == 1
|
||||
assert client._remote_message_protocol is not None, "a session exists now"
|
||||
assert client._reconnect_task is not None, (
|
||||
"keep_reconnecting only maintains a session; a task with no session "
|
||||
"would be the pre-t15 no-op"
|
||||
)
|
||||
assert manager.protocol_state == "connected"
|
||||
assert manager.protocol_connected is True
|
||||
assert manager.protocol_ever_authenticated is True
|
||||
assert manager.protocol_last_error is None
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_keep_reconnecting_alone_would_stay_unconnected(self):
|
||||
"""The fake reproduces the library: without async_connect nothing happens."""
|
||||
client = FakeProtocolClient()
|
||||
client.keep_reconnecting(lambda: None)
|
||||
await _drain_loop()
|
||||
|
||||
assert client._reconnect_task.done(), (
|
||||
"the library's loop returns at once with no session — which is why "
|
||||
"keep_reconnecting() alone never connected anything"
|
||||
)
|
||||
assert client.connect_calls == 0
|
||||
|
||||
async def test_network_failure_keeps_retrying_and_stays_never_connected(self):
|
||||
client = FakeProtocolClient(connect_error=CannotConnect("unreachable"))
|
||||
manager = _protocol_manager(client)
|
||||
_fast_backoff(manager)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert manager.protocol_state == "never_connected"
|
||||
assert manager.protocol_connected is False
|
||||
assert "CannotConnect" in (manager.protocol_last_error or "")
|
||||
assert client.keep_reconnecting_calls == 0, (
|
||||
"nothing to maintain until a session exists"
|
||||
)
|
||||
|
||||
for _ in range(200): # the retry loop must keep trying
|
||||
if client.connect_calls >= 2:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
assert client.connect_calls >= 2, "a network failure is retried with backoff"
|
||||
assert manager.protocol_ever_authenticated is False
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_auth_rejection_stops_and_runs_the_reauth_hook(self):
|
||||
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
|
||||
fired = []
|
||||
manager = _protocol_manager(
|
||||
client, invalid_auth_callback=lambda: fired.append(True)
|
||||
)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert manager.protocol_state == "auth_rejected"
|
||||
assert fired == [True], "the entry's reauth/pair flow is the answer"
|
||||
assert "InvalidAuth" in (manager.protocol_last_error or "")
|
||||
assert client.keep_reconnecting_calls == 0
|
||||
|
||||
connect_calls = client.connect_calls
|
||||
await asyncio.sleep(0.05)
|
||||
assert client.connect_calls == connect_calls, (
|
||||
"retrying a rejected certificate can never succeed"
|
||||
)
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_auth_rejection_after_a_session_marks_the_state(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
assert manager.protocol_state == "connected"
|
||||
|
||||
client.fire_invalid_auth()
|
||||
|
||||
assert manager.protocol_state == "auth_rejected"
|
||||
assert manager.protocol_last_error # readable, not only logged
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestHonestSendFailures:
|
||||
"""t15: a send that could not run must never look like one that did."""
|
||||
|
||||
async def test_send_without_a_session_warns_and_records(self, caplog):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
client._remote_message_protocol = None # session lost (library no-ops sends)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
sent = await manager.send_protocol_key("POWER")
|
||||
|
||||
assert sent is False
|
||||
assert manager.protocol_failures == 1
|
||||
assert "POWER" in (manager.protocol_last_error or "")
|
||||
assert "ConnectionClosed" in (manager.protocol_last_error or "")
|
||||
assert any(
|
||||
"did not run" in rec.message for rec in caplog.records
|
||||
), "the failure is a WARNING carrying what happened"
|
||||
assert client.keys_sent == []
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_launch_without_a_transport_warns_and_records(self, caplog):
|
||||
manager = _protocol_manager(None) # protocol disabled for this entry
|
||||
with caplog.at_level("WARNING"):
|
||||
sent = await manager.launch_app("com.netflix.ninja")
|
||||
|
||||
assert sent is False
|
||||
assert manager.protocol_failures == 1
|
||||
assert "no protocol transport" in (manager.protocol_last_error or "")
|
||||
assert any("did not run" in rec.message for rec in caplog.records)
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_working_send_is_not_counted_as_a_failure(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert await manager.send_protocol_key("POWER") is True
|
||||
assert await manager.launch_app("com.netflix.ninja") is True
|
||||
|
||||
assert client.keys_sent == [("POWER", "SHORT")]
|
||||
assert client.apps_launched == ["com.netflix.ninja"]
|
||||
assert manager.protocol_failures == 0
|
||||
assert manager.protocol_last_error is None
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_availability_push_drives_the_connected_state(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
client.push_is_available(False)
|
||||
await _drain_loop()
|
||||
assert manager.protocol_state == "disconnected"
|
||||
assert manager.protocol_connected is False
|
||||
|
||||
client.push_is_available(True)
|
||||
await _drain_loop()
|
||||
assert manager.protocol_state == "connected"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_push_proves_an_authenticated_session(self):
|
||||
"""The library exposes no is_paired: a push is the only proof there is."""
|
||||
client = FakeProtocolClient(connect_error=CannotConnect("down"))
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
assert manager.protocol_ever_authenticated is False
|
||||
|
||||
client.push_current_app("com.netflix.ninja")
|
||||
await _drain_loop()
|
||||
|
||||
assert manager.protocol_ever_authenticated is True
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_transport_state_is_off_without_a_client(self):
|
||||
manager = _protocol_manager(None)
|
||||
await manager._start_protocol()
|
||||
assert manager.protocol_state == "off"
|
||||
assert manager.protocol_connected is False
|
||||
assert manager.protocol_ever_authenticated is False
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_stop_disconnects_lib_owned_reconnect_task(self):
|
||||
"""The library owns keep_reconnecting's task; closing the client
|
||||
@@ -163,6 +447,7 @@ class TestLifecycle:
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
task = client._reconnect_task
|
||||
assert task is not None
|
||||
|
||||
@@ -184,7 +469,7 @@ class TestLifecycle:
|
||||
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=None,
|
||||
entry=_adb_entry(),
|
||||
protocol_settings={
|
||||
"host": "shield.local",
|
||||
"client_name": "Home Assistant",
|
||||
@@ -291,7 +576,7 @@ class TestAdbOutageKeepsProtocolTruth:
|
||||
poller.awake = None # wake poll fails → cycle failure path
|
||||
client = FakeProtocolClient()
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, protocol=client
|
||||
hass=None, entry=_adb_entry(), poller=poller, protocol=client
|
||||
)
|
||||
await manager._start_protocol()
|
||||
|
||||
@@ -315,6 +600,117 @@ class TestAdbOutageKeepsProtocolTruth:
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestAdbStaysThePrimarySource:
|
||||
"""t15: arming the protocol must not promote its pushes over ADB's readings.
|
||||
|
||||
The pushes this unit turns ON (is_on / is_available / current_app / volume) had
|
||||
NEVER been live here — ``keep_reconnecting`` alone never connected — so the whole
|
||||
protocol surface is new traffic next to an ADB media-session poller the user
|
||||
relies on. The store's fields are disjoint by design; these tests pin that, and
|
||||
that an ADB-only entry keeps working with only the protocol surface degraded.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _adbreath(manager):
|
||||
"""Every field the ADB poller owns, stamped, and read back."""
|
||||
manager.store.set(FIELD_AWAKE, True)
|
||||
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
||||
manager.store.set(FIELD_AUDIO_PLAYBACK, ["com.audio.last"])
|
||||
manager.store.set(FIELD_RUNNING_APPS, [{"package": "com.run.now"}])
|
||||
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
|
||||
return {
|
||||
field: manager.store.get(field)
|
||||
for field in (
|
||||
FIELD_AWAKE,
|
||||
FIELD_SESSIONS,
|
||||
FIELD_AUDIO_PLAYBACK,
|
||||
FIELD_RUNNING_APPS,
|
||||
FIELD_FOREGROUND,
|
||||
)
|
||||
}
|
||||
|
||||
async def test_a_protocol_push_never_overwrites_an_adb_reading(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
before = self._adbreath(manager)
|
||||
|
||||
client.push_is_on(True)
|
||||
client.push_is_available(True)
|
||||
client.push_current_app("com.protocol.foreground")
|
||||
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
||||
await _drain_loop()
|
||||
|
||||
assert {
|
||||
field: manager.store.get(field) for field in before
|
||||
} == before, "ADB's own fields are untouched by every protocol push"
|
||||
# ...and the protocol wrote exactly its own four
|
||||
assert manager.is_on is True
|
||||
assert manager.is_available is True
|
||||
assert manager.current_app == "com.protocol.foreground"
|
||||
assert manager.volume_info == (5.0, 15.0, False)
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_the_foreground_identity_falls_back_to_adb(self):
|
||||
"""No push yet: the ADB parse answers, and nothing is invented."""
|
||||
client = FakeProtocolClient(connect_error=CannotConnect("down"))
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
self._adbreath(manager)
|
||||
|
||||
assert manager.current_app is None, "no protocol push ever arrived"
|
||||
assert manager.foreground_app == "com.adb.foreground"
|
||||
assert manager.foreground_source == "adb", "the tier is named, not implied"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_push_wins_while_fresh_and_the_adb_reading_survives_it(self):
|
||||
"""The documented priority, and no loss: an expired push re-exposes ADB."""
|
||||
now = [1000.0]
|
||||
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client, store=store)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
self._adbreath(manager)
|
||||
client.push_current_app("com.protocol.foreground")
|
||||
await _drain_loop()
|
||||
|
||||
assert manager.foreground_app == "com.protocol.foreground"
|
||||
assert manager.foreground_source == "protocol"
|
||||
assert manager.store.get(FIELD_FOREGROUND) == "com.adb.foreground", (
|
||||
"the push wrote its OWN field — the ADB reading was not clobbered"
|
||||
)
|
||||
|
||||
now[0] += 60.1 # both fields expire on the store's own TTL, not by a push
|
||||
|
||||
assert manager.current_app is None
|
||||
assert manager.store.get(FIELD_FOREGROUND) is None
|
||||
# re-stamped (the next ADB poll), the ADB reading answers again untouched
|
||||
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
|
||||
assert manager.foreground_app == "com.adb.foreground"
|
||||
assert manager.foreground_source == "adb"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_an_adb_only_entry_keeps_working(self):
|
||||
"""No protocol client at all: ADB truth refreshes, only the transport degrades."""
|
||||
poller = FakeAdbForOutage()
|
||||
poller.connected = True
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=_adb_entry(), poller=poller
|
||||
)
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol is None
|
||||
assert manager.protocol_state == "off"
|
||||
assert manager.protocol_connected is False
|
||||
assert manager.protocol_ever_authenticated is False
|
||||
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestImportGuard:
|
||||
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
|
||||
"""F1: a protocol-enabled entry without androidtvremote2 keeps
|
||||
@@ -362,3 +758,345 @@ class TestVolumeTuple:
|
||||
androidtvremote2 = pytest.importorskip("androidtvremote2")
|
||||
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
|
||||
assert volume_tuple(info) == (3.0, 15.0, True)
|
||||
|
||||
|
||||
# ── t20: the t19 review's protocol findings ──
|
||||
|
||||
|
||||
class HangingProtocolClient(FakeProtocolClient):
|
||||
"""``async_connect()`` that never returns until the test says so (t20).
|
||||
|
||||
The lifecycle findings are about what happens WHILE an attempt is in flight,
|
||||
so the fake has to be able to sit inside ``async_connect``: it parks on an
|
||||
event, records how many times it was cancelled, and only then - if it is ever
|
||||
released - becomes a session.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.entered = asyncio.Event()
|
||||
self.release = asyncio.Event()
|
||||
self.cancelled = 0
|
||||
|
||||
async def async_connect(self):
|
||||
self.connect_calls += 1
|
||||
self.entered.set()
|
||||
try:
|
||||
await self.release.wait()
|
||||
except asyncio.CancelledError:
|
||||
self.cancelled += 1
|
||||
raise
|
||||
self._remote_message_protocol = object()
|
||||
|
||||
|
||||
class OnceFailingProtocolClient(FakeProtocolClient):
|
||||
"""Fails its first attempts, then connects - the RECOVERY sequence (t20/O1)."""
|
||||
|
||||
def __init__(self, failures: int = 1, error: Exception | None = None):
|
||||
super().__init__()
|
||||
self._failures_left = failures
|
||||
self._error = error or CannotConnect("Net: boom")
|
||||
|
||||
async def async_connect(self):
|
||||
self.connect_calls += 1
|
||||
if self._failures_left > 0:
|
||||
self._failures_left -= 1
|
||||
raise self._error
|
||||
self._remote_message_protocol = object()
|
||||
|
||||
|
||||
class TestRecoveredErrorsAreNotStale:
|
||||
"""t20/O1: the published pair can never contradict itself.
|
||||
|
||||
``protocol_state``/``protocol_connected`` is the transport's CURRENT
|
||||
condition; ``protocol_last_error`` is the failure the CURRENT state has not
|
||||
recovered from. It is cleared whenever a session becomes proven and kept while
|
||||
the transport is still down (and, deliberately, kept for a failure that
|
||||
happened AFTER the session came up - that one is current, not stale).
|
||||
"""
|
||||
|
||||
async def test_failure_then_recovery_publishes_no_stale_error(self):
|
||||
"""The measured defect: 'connect: Net: boom' beside connected: true."""
|
||||
client = OnceFailingProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
_fast_backoff(manager)
|
||||
await manager._start_protocol()
|
||||
|
||||
for _ in range(200): # the failure is recorded while it is still down
|
||||
if manager.protocol_last_error:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
assert manager.protocol_state == "never_connected"
|
||||
assert manager.protocol_connected is False
|
||||
assert "CannotConnect" in (manager.protocol_last_error or "")
|
||||
|
||||
for _ in range(400): # the retry succeeds
|
||||
if manager.protocol_connected:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
assert manager.protocol_connected is True
|
||||
assert manager.protocol_last_error is None, (
|
||||
"a recovered failure must not be published beside connected: true"
|
||||
)
|
||||
assert manager.protocol_failures == 0, "a connect failure is not a SEND failure"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_failure_then_still_disconnected_keeps_the_error(self):
|
||||
"""The other direction: a failure that still describes the transport stays."""
|
||||
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
|
||||
manager = _protocol_manager(client)
|
||||
manager._jitter = lambda delay: 60.0 # one attempt, then sleep past the test
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert manager.protocol_state == "never_connected"
|
||||
assert manager.protocol_connected is False
|
||||
assert "Net: boom" in (manager.protocol_last_error or "")
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_send_failure_before_a_session_is_cleared_by_the_connect(self):
|
||||
"""The review's exact sequence, driven through public calls."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
|
||||
assert await manager.send_protocol_key("POWER") is False # no session yet
|
||||
assert manager.protocol_last_error, "the current failure is recorded"
|
||||
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
assert manager.protocol_connected is True
|
||||
assert manager.protocol_last_error is None, "recovery clears it"
|
||||
assert manager.protocol_failures == 1, "history stays in the counter"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_an_availability_push_also_clears_a_recovered_error(self):
|
||||
"""A push is the OTHER proof of a live session, so it clears it too."""
|
||||
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
|
||||
manager = _protocol_manager(client)
|
||||
manager._jitter = lambda delay: 60.0 # the retry cannot race this assertion
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
assert manager.protocol_last_error
|
||||
|
||||
client.push_is_available(True)
|
||||
await _drain_loop()
|
||||
|
||||
assert manager.protocol_state == "connected"
|
||||
assert manager.protocol_last_error is None
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_send_failure_after_the_session_came_up_is_current(self):
|
||||
"""Not the same thing as stale: the session failed just now."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
assert manager.protocol_last_error is None
|
||||
|
||||
client._remote_message_protocol = None # the session is gone
|
||||
assert await manager.send_protocol_key("POWER") is False
|
||||
assert "POWER" in (manager.protocol_last_error or "")
|
||||
|
||||
# ...and a session proven again clears it
|
||||
client._remote_message_protocol = object()
|
||||
client.push_is_available(True)
|
||||
await _drain_loop()
|
||||
assert manager.protocol_last_error is None
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_recovery_notifies_listeners_because_a_published_value_changed(self):
|
||||
"""The error is an attribute: clearing it is a state change to publish."""
|
||||
manager = _protocol_manager(FakeProtocolClient())
|
||||
notified = []
|
||||
manager.register_listener(lambda: notified.append(manager.protocol_last_error))
|
||||
|
||||
await manager.send_protocol_key("POWER") # records an error
|
||||
assert manager.protocol_last_error
|
||||
before = len(notified)
|
||||
|
||||
manager._note_protocol_state(PROTOCOL_CONNECTED, None)
|
||||
|
||||
assert len(notified) > before, "listeners were told the error went away"
|
||||
assert manager.protocol_last_error is None
|
||||
|
||||
|
||||
class TestConnectAttemptLifecycle:
|
||||
"""t20/O4 + O5: the connect attempt is tracked, and a second start is defined.
|
||||
|
||||
Both findings interact with the options UI: every save reloads the entry, so an
|
||||
attempt that survives its entry (or a start that orphans an earlier one) can
|
||||
accumulate reconnect loops across saves.
|
||||
"""
|
||||
|
||||
async def test_unload_during_a_connect_attempt_cancels_and_awaits_it(self):
|
||||
"""The proof for O4: no live task, and no further connect attempts."""
|
||||
client = HangingProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
_fast_backoff(manager)
|
||||
await manager._start_protocol()
|
||||
await asyncio.wait_for(client.entered.wait(), 1)
|
||||
|
||||
task = manager._protocol_connect_task
|
||||
assert task is not None and not task.done()
|
||||
assert task in manager._tasks, "tracked, so unload can cancel it (O4)"
|
||||
assert client.connect_calls == 1
|
||||
|
||||
await manager.async_stop()
|
||||
|
||||
assert task.done(), "the unload AWAITED the attempt"
|
||||
assert task.cancelled()
|
||||
assert client.cancelled == 1, "the in-flight connect was cancelled"
|
||||
assert manager._tasks == [], "nothing is left to leak into the next reload"
|
||||
|
||||
calls = client.connect_calls
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.01)
|
||||
assert client.connect_calls == calls, "no attempt survives the entry"
|
||||
|
||||
async def test_async_start_tracks_every_loop_and_stop_reaps_them(self):
|
||||
client = HangingProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
_fast_backoff(manager)
|
||||
|
||||
await manager.async_start() # the watchdog (passive here) + the connect
|
||||
await asyncio.wait_for(client.entered.wait(), 1)
|
||||
tracked = list(manager._tasks)
|
||||
assert len(tracked) == 2, [t.get_name() for t in tracked]
|
||||
|
||||
await manager.async_stop()
|
||||
|
||||
assert manager._tasks == []
|
||||
assert all(task.done() for task in tracked), "both were awaited"
|
||||
|
||||
async def test_a_second_start_reuses_the_live_attempt(self):
|
||||
"""The proof for O5: starting twice leaves exactly ONE live attempt."""
|
||||
client = HangingProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await asyncio.wait_for(client.entered.wait(), 1)
|
||||
first = manager._protocol_connect_task
|
||||
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol_connect_task is first, "reused, never orphaned"
|
||||
assert client.connect_calls == 1, "exactly one attempt was made"
|
||||
assert [t for t in manager._tasks if not t.done()] == [first]
|
||||
assert manager._tasks.count(first) == 1, "registered once, not once per start"
|
||||
assert client._is_on_callbacks == [manager._on_is_on], (
|
||||
"the push callbacks are not registered twice either"
|
||||
)
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_second_start_after_a_session_does_not_build_a_second_loop(self):
|
||||
"""The library owns the session; a second keep_reconnecting would be a leak."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
first = manager._protocol_connect_task
|
||||
assert manager.protocol_connected is True
|
||||
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol_connect_task is first
|
||||
assert client.connect_calls == 1
|
||||
assert client.keep_reconnecting_calls == 1, "one owner for the session"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_second_start_after_a_terminal_failure_tries_again(self):
|
||||
"""Deterministic in BOTH directions: a finished attempt may be replaced."""
|
||||
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
|
||||
manager = _protocol_manager(client, invalid_auth_callback=lambda: None)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
first = manager._protocol_connect_task
|
||||
assert first.done(), "the auth path is terminal: the loop returned"
|
||||
assert manager.protocol_state == "auth_rejected"
|
||||
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol_connect_task is not first, "a FRESH attempt replaces it"
|
||||
for _ in range(200): # the fresh task needs a turn to make its attempt
|
||||
if client.connect_calls == 2:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
assert client.connect_calls == 2, "a restart really retries"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_stopped_manager_is_not_restarted_by_a_late_call(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager.async_stop()
|
||||
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol_connect_task is None
|
||||
assert manager._tasks == []
|
||||
|
||||
|
||||
class TestTransportBannersAreNotStale:
|
||||
"""t20/O2: define stale, then make it impossible.
|
||||
|
||||
A STALE banner is one whose transport claim a later state change never followed
|
||||
up on, so the log's last word contradicts the entity. The rules that remove it:
|
||||
the affirmative banner states only what the attempt proved, and EVERY state
|
||||
change announces itself with both the old and the new state.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _state_lines(records):
|
||||
return [r for r in records if "transport state:" in r.getMessage()]
|
||||
|
||||
async def test_the_connected_banner_claims_no_liveliness(self, caplog):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
|
||||
assert opened, "connecting is announced"
|
||||
text = opened[0].getMessage()
|
||||
assert "what this proves" in text, "it names its own limit"
|
||||
assert "availability push" in text, "and who maintains the session from there"
|
||||
assert "paired" not in text.lower(), "a connect attempt cannot prove pairing"
|
||||
|
||||
lines = self._state_lines(caplog.records)
|
||||
assert lines, "the transition is announced"
|
||||
assert lines[-1].getMessage().endswith(manager.protocol_state)
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_a_later_state_change_announces_itself(self, caplog):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
client.push_is_available(False) # the device reports the session down
|
||||
await _drain_loop()
|
||||
|
||||
lines = self._state_lines(caplog.records)
|
||||
assert lines, "a session going down is announced"
|
||||
assert lines[-1].getMessage().endswith("disconnected")
|
||||
assert manager.protocol_state == "disconnected", "the banner matches the entity"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_the_open_banner_and_the_transition_agree(self, caplog):
|
||||
"""No two lines about the same moment may name different states."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
await manager._start_protocol()
|
||||
await _connect(manager, client)
|
||||
|
||||
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
|
||||
lines = self._state_lines(caplog.records)
|
||||
assert opened and lines
|
||||
assert "connected" in opened[0].getMessage()
|
||||
assert lines[-1].getMessage().endswith("connected")
|
||||
await manager.async_stop()
|
||||
@@ -70,3 +70,74 @@ def test_the_readme_states_the_on_rule_precisely(readme_text):
|
||||
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
|
||||
assert shape in readme_text, shape
|
||||
assert "no source reported anything at all" not in readme_text
|
||||
|
||||
|
||||
# ── t17: the intents documentation ──
|
||||
|
||||
#: The keys a seeded intent row may carry (the seed validator in config_flow.py
|
||||
#: decides what it ACCEPTS; these are the names the README may use).
|
||||
INTENT_ROW_KEYS = {
|
||||
"id",
|
||||
"name",
|
||||
"package",
|
||||
"activity",
|
||||
"action",
|
||||
"categories",
|
||||
"data",
|
||||
"fields",
|
||||
}
|
||||
|
||||
#: The keys one declared field may carry.
|
||||
INTENT_FIELD_KEYS = {"key", "type", "required", "default"}
|
||||
|
||||
|
||||
def _intents_example(text: str) -> str:
|
||||
"""The `registered_intents:` block of the README's seed example (t17)."""
|
||||
match = re.search(
|
||||
r"```yaml\n(?:.*\n)*? registered_intents:\n((?:.*\n)*?)```", text
|
||||
)
|
||||
assert match, "the README has no `registered_intents:` example block"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_the_intents_example_only_uses_keys_the_seed_honours(readme_text):
|
||||
"""Documented-but-ignored is the failure this pins (t30's rule, nested)."""
|
||||
block = _intents_example(readme_text)
|
||||
row_keys = set(re.findall(r"^\s*([a-z_]+):", block, re.M))
|
||||
field_keys = set(re.findall(r"[{,]\s*([a-z_]+):", block))
|
||||
|
||||
assert row_keys, "the intents example declares no row keys"
|
||||
assert row_keys <= INTENT_ROW_KEYS, sorted(row_keys - INTENT_ROW_KEYS)
|
||||
assert field_keys <= INTENT_ROW_KEYS | INTENT_FIELD_KEYS, sorted(
|
||||
field_keys - INTENT_ROW_KEYS - INTENT_FIELD_KEYS
|
||||
)
|
||||
|
||||
|
||||
def test_the_intents_screen_and_action_are_documented(readme_text):
|
||||
"""The screen, its fields, the button and the action are all named (t17)."""
|
||||
for needle in (
|
||||
"### Intents (buttons and the `send_intent` action)",
|
||||
"Configure → **Intents**",
|
||||
"Variables (declared fields)",
|
||||
"One button per registered intent",
|
||||
"nvidia_shield.send_intent",
|
||||
"intent_response",
|
||||
):
|
||||
assert needle in readme_text, needle
|
||||
|
||||
|
||||
def test_the_intents_copy_states_the_honest_failures(readme_text):
|
||||
"""The README names each way this can go wrong — and that it RAISES (t17)."""
|
||||
for key in (
|
||||
"unknown_intent",
|
||||
"missing_intent_field",
|
||||
"unknown_intent_variable",
|
||||
"invalid_intent_field",
|
||||
"intent_failed",
|
||||
"intents_need_adb",
|
||||
):
|
||||
assert key in readme_text, key
|
||||
assert "configuration problem" in readme_text
|
||||
# the sentence wraps across lines in the source, so compare it flattened
|
||||
flat = " ".join(readme_text.split())
|
||||
assert "no `eval`, no `format()`, no shell interpolation" in flat
|
||||
@@ -0,0 +1,383 @@
|
||||
"""t66: the opt-in screen-capture IMAGE entity.
|
||||
|
||||
WHAT IS PROVEN HERE, AND WHAT IS NOT
|
||||
------------------------------------
|
||||
Proven: the bytes that reach Home Assistant are REAL PNG bytes — ``PNG_B64``
|
||||
below is a real 2x1 PNG, base64'd on the wire exactly the way
|
||||
``screencap -p | base64`` sends it — the entity hands HA ``bytes`` and never a
|
||||
``str``, a disconnected link or a bad answer makes the entity UNAVAILABLE
|
||||
instead of serving a blank/zero-byte image, one capture is coalesced and reused
|
||||
so a dashboard cannot hammer the device, and the option defaults OFF so no
|
||||
entity exists until the user opts in.
|
||||
|
||||
NOT proven: anything about a real Shield. No device was available in this
|
||||
environment, so ``screencap``'s flags, the device's base64 and — the important
|
||||
one — how the persistent ADB socket copes with a few hundred kilobytes of
|
||||
base64 in a single ``shell()`` call are INFERRED, not measured. ``FakeAdbPoller``
|
||||
answers the way a working Shield should.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
from entity_stublib import install, load_entity_modules
|
||||
|
||||
#: A REAL 2x1 PNG (one red pixel, one green pixel) — 72 bytes, base64. It is the
|
||||
#: wire form the device sends, so the fixture exercises the real decode path
|
||||
#: rather than an invented one.
|
||||
PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
|
||||
"Af+4BDXgAAAAAElFTkSuQmCC"
|
||||
)
|
||||
PNG_BYTES = base64.b64decode(PNG_B64)
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def wrapped(text: str, width: int = 76) -> str:
|
||||
"""``base64``'s own line wrapping — the device wraps, and that is not corruption."""
|
||||
return "\n".join(text[i : i + width] for i in range(0, len(text), width)) + "\n"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
install()
|
||||
yield load_entity_modules()
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture(name="modules")
|
||||
def load_modules_fixture():
|
||||
return load_entity_modules()
|
||||
|
||||
|
||||
class FakeAdbPoller:
|
||||
"""The ADB seam the entity is allowed to use: ``connected`` + ``shell``."""
|
||||
|
||||
def __init__(self, answer=None, *, connected=True, delay=0.0):
|
||||
self.connected = connected
|
||||
self.answer = answer
|
||||
self.delay = delay
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, cmd: str):
|
||||
self.calls.append(cmd)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if isinstance(self.answer, Exception):
|
||||
raise self.answer
|
||||
if callable(self.answer):
|
||||
return self.answer(cmd)
|
||||
return self.answer
|
||||
|
||||
|
||||
class StrictManager:
|
||||
"""Only ``adb_poller`` may be touched — nothing else exists on purpose.
|
||||
|
||||
"Never a state source" is a claim about what the capture path reads and
|
||||
writes: it reads the ADB poller for a screen grab and writes nothing at all.
|
||||
A manager that raises on every other attribute turns a future reach-in (a
|
||||
store write, a state push) into a failing test instead of a quiet coupling
|
||||
between screen capture and the state pipeline.
|
||||
"""
|
||||
|
||||
def __init__(self, poller):
|
||||
self.adb_poller = poller
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError(f"the screen-capture path touched manager.{name}")
|
||||
|
||||
|
||||
def make_entry(options=None, *, entry_id="e1", title="Shield"):
|
||||
return SimpleNamespace(
|
||||
entry_id=entry_id, title=title, options=dict(options or {}), runtime_data=None
|
||||
)
|
||||
|
||||
|
||||
def make_entity(modules, poller, options=None):
|
||||
"""The entity over a fake manager on the entry, plus the entry it belongs to."""
|
||||
entry = make_entry(options or {"screen_capture_enabled": True})
|
||||
if poller is not None:
|
||||
entry.runtime_data = StrictManager(poller)
|
||||
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
|
||||
return entity, entry
|
||||
|
||||
|
||||
def fake_clock(modules, monkeypatch, start=1000.0):
|
||||
"""Freeze ``time.monotonic`` for the capture-window tests. Returns the cell."""
|
||||
now = [start]
|
||||
monkeypatch.setattr(
|
||||
modules["image"], "time", SimpleNamespace(monotonic=lambda: now[0])
|
||||
)
|
||||
return now
|
||||
|
||||
|
||||
# ── the decode path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureDecode:
|
||||
"""``decode_screen_capture`` — the one place an image could be corrupted."""
|
||||
|
||||
def test_a_real_png_survives_the_wire_format(self, modules):
|
||||
decode = modules["image"].decode_screen_capture
|
||||
|
||||
data = decode(wrapped(PNG_B64))
|
||||
|
||||
assert data == PNG_BYTES # byte-for-byte, wrapping and all
|
||||
assert data.startswith(PNG_SIGNATURE)
|
||||
assert isinstance(data, bytes) # not a str: this is base64-decoded
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"answer",
|
||||
[
|
||||
None, # the transport never answered (AdbClientBase.shell's failure)
|
||||
"", # an empty answer is not an image
|
||||
"not base64 at all !!",
|
||||
base64.b64encode(b"hello world, definitely not a png").decode(),
|
||||
# a signature and nothing else: not a PNG (the IHDR chunk is missing)
|
||||
base64.b64encode(PNG_SIGNATURE + b"\x00" * 8).decode(),
|
||||
],
|
||||
)
|
||||
def test_everything_that_is_not_a_png_is_rejected(self, modules, answer):
|
||||
assert modules["image"].decode_screen_capture(answer) is None
|
||||
|
||||
def test_a_device_error_line_is_not_an_image(self, modules):
|
||||
polluted = "error: no display\n" + wrapped(PNG_B64)
|
||||
|
||||
assert modules["image"].decode_screen_capture(polluted) is None
|
||||
|
||||
|
||||
# ── the entity ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureEntity:
|
||||
async def test_the_entity_serves_real_png_bytes_and_never_a_str(self, modules):
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
data = await entity.async_image()
|
||||
|
||||
assert data == PNG_BYTES
|
||||
assert data.startswith(PNG_SIGNATURE)
|
||||
assert not isinstance(data, str)
|
||||
assert entity.available is True
|
||||
assert entity.capture_status == "ok"
|
||||
assert entity.extra_state_attributes["capture_status"] == "ok"
|
||||
|
||||
async def test_the_command_is_the_base64_route_and_needs_no_device_file(self, modules):
|
||||
"""Route 2 of the module docstring, and the reason it was chosen."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
|
||||
assert poller.calls == ["screencap -p | base64"]
|
||||
# No device-side artifact exists, so there is nothing to clean up...
|
||||
assert "/sdcard" not in poller.calls[0]
|
||||
assert "rm " not in poller.calls[0]
|
||||
|
||||
async def test_a_disconnected_link_is_unavailable_and_is_never_asked(self, modules):
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64), connected=False)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "adb_disconnected"
|
||||
assert await entity.async_image() is None
|
||||
assert poller.calls == [] # a dead link is not poked for a screenshot
|
||||
|
||||
async def test_adb_disabled_has_its_own_reason(self, modules):
|
||||
entry = make_entry({"screen_capture_enabled": True})
|
||||
manager = SimpleNamespace(adb_poller=None)
|
||||
entry.runtime_data = manager
|
||||
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
|
||||
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "adb_disabled"
|
||||
assert await entity.async_image() is None
|
||||
|
||||
async def test_an_empty_answer_is_unavailable_not_a_blank_image(self, modules):
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "capture_failed"
|
||||
|
||||
async def test_a_transport_that_raises_is_a_failure_not_a_crash(self, modules):
|
||||
poller = FakeAdbPoller(RuntimeError("socket closed under us"))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "capture_failed"
|
||||
|
||||
async def test_a_failed_capture_recovers_on_the_next_request(self, modules):
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
|
||||
poller.answer = wrapped(PNG_B64) # the device came back
|
||||
|
||||
assert await entity.async_image() == PNG_BYTES
|
||||
assert entity.available is True
|
||||
assert entity.capture_status == "ok"
|
||||
|
||||
async def test_one_capture_is_reused_inside_the_window(self, modules, monkeypatch):
|
||||
"""A dashboard repaint is not a screen grab per repaint."""
|
||||
now = fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
first = await entity.async_image()
|
||||
now[0] += modules["image"].CAPTURE_REUSE_SECONDS - 1.0
|
||||
second = await entity.async_image()
|
||||
|
||||
assert first == second == PNG_BYTES
|
||||
assert len(poller.calls) == 1 # one shell, two image requests
|
||||
|
||||
async def test_the_window_expires_so_a_later_view_refreshes(self, modules, monkeypatch):
|
||||
now = fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
now[0] += modules["image"].CAPTURE_REUSE_SECONDS + 1.0
|
||||
await entity.async_image()
|
||||
|
||||
assert len(poller.calls) == 2
|
||||
|
||||
async def test_an_explicit_refresh_skips_the_reuse_window(self, modules, monkeypatch):
|
||||
"""``homeassistant.update_entity`` means "grab it now"."""
|
||||
fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
await entity.async_update()
|
||||
|
||||
assert len(poller.calls) == 2
|
||||
|
||||
async def test_concurrent_requests_share_one_capture(self, modules):
|
||||
"""Parallel image fetches must not leak ADB shells."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64), delay=0.01)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
results = await asyncio.gather(
|
||||
entity.async_image(), entity.async_image(), entity.async_image()
|
||||
)
|
||||
|
||||
assert list(results) == [PNG_BYTES, PNG_BYTES, PNG_BYTES]
|
||||
assert len(poller.calls) == 1
|
||||
|
||||
async def test_capture_reads_the_adb_poller_and_drives_nothing(self, modules):
|
||||
"""The strict manager raises if the capture path touches anything else."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller) # StrictManager
|
||||
|
||||
assert await entity.async_image() == PNG_BYTES
|
||||
|
||||
# ...and the state HA would record is the timestamp, never the bytes
|
||||
assert entity.image_last_updated is not None
|
||||
assert isinstance(entity.image_last_updated, datetime)
|
||||
assert entity.image_last_updated.tzinfo is not None
|
||||
|
||||
async def test_image_last_updated_is_stamped_only_by_a_success(self, modules, monkeypatch):
|
||||
fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert entity.image_last_updated is None # nothing captured yet
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.image_last_updated is None # a failure stamps nothing
|
||||
|
||||
poller.answer = wrapped(PNG_B64)
|
||||
await entity.async_image()
|
||||
stamped = entity.image_last_updated
|
||||
|
||||
assert stamped is not None
|
||||
poller.answer = None # a later failure must not move it
|
||||
await entity.async_image()
|
||||
assert entity.image_last_updated is stamped
|
||||
|
||||
def test_entity_hygiene(self, modules):
|
||||
"""Naming, content type, recorder hygiene, and the no-poll rule."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, entry = make_entity(modules, poller)
|
||||
cls = type(entity)
|
||||
|
||||
assert cls._attr_has_entity_name is True
|
||||
assert entity.translation_key == "screen_capture"
|
||||
assert entity.content_type == "image/png"
|
||||
# HA polls image entities never by default; this one must not either
|
||||
assert cls._attr_should_poll is False
|
||||
assert entity.should_poll is False
|
||||
assert entity.unique_id == f"nvidia_shield_{entry.entry_id}_screen_capture"
|
||||
assert entity.device_info["identifiers"] == {("nvidia_shield", entry.entry_id)}
|
||||
# the status changes with a capture, so it stays out of the database, and
|
||||
# no attribute ever carries the image bytes
|
||||
assert "capture_status" in cls._unrecorded_attributes
|
||||
for value in entity.extra_state_attributes.values():
|
||||
assert not isinstance(value, (bytes, bytearray))
|
||||
|
||||
def test_the_platform_is_forwarded(self, modules):
|
||||
"""The new platform is in the entry's list, and the manifest still parses."""
|
||||
platforms = [str(p) for p in sys.modules["nsh.init"].PLATFORMS]
|
||||
|
||||
assert "image" in platforms
|
||||
assert platforms.count("image") == 1
|
||||
|
||||
manifest = json.loads(
|
||||
(_SHIELD_DIR / "manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
# HA discovers platforms from the forwarded list; a "platforms" key in the
|
||||
# manifest is not a thing (and adding one would be a silent no-op).
|
||||
assert "platforms" not in manifest
|
||||
assert "adb-shell>=0.4.0" in manifest["requirements"]
|
||||
|
||||
|
||||
# ── opt-in ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureOptIn:
|
||||
"""Default OFF: no option, no entity, no cost."""
|
||||
|
||||
async def _setup(self, modules, options):
|
||||
added: list = []
|
||||
entry = make_entry(options)
|
||||
entry.runtime_data = StrictManager(FakeAdbPoller(wrapped(PNG_B64)))
|
||||
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
|
||||
return added
|
||||
|
||||
async def test_the_option_defaults_off_and_creates_no_entity(self, modules):
|
||||
assert await self._setup(modules, {}) == []
|
||||
|
||||
assert sys.modules["nsh.const"].CONF_SCREEN_CAPTURE_ENABLED == (
|
||||
"screen_capture_enabled"
|
||||
)
|
||||
assert await self._setup(modules, {"screen_capture_enabled": False}) == []
|
||||
|
||||
async def test_switching_it_on_creates_exactly_one_entity(self, modules):
|
||||
added = await self._setup(modules, {"screen_capture_enabled": True})
|
||||
|
||||
assert len(added) == 1
|
||||
assert type(added[0]).__name__ == "NvidiaShieldScreenImage"
|
||||
|
||||
async def test_an_entry_without_a_manager_is_skipped_not_crashed(self, modules):
|
||||
added: list = []
|
||||
entry = make_entry({"screen_capture_enabled": True})
|
||||
entry.runtime_data = None
|
||||
|
||||
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
|
||||
|
||||
assert added == []
|
||||
@@ -0,0 +1,478 @@
|
||||
"""t17: `nvidia_shield.send_intent` against a REAL Home Assistant install.
|
||||
|
||||
The intents feature has one claim a stub cannot settle: that the action RETURNS
|
||||
the device's output, and that the frontend is offered the registered intents as a
|
||||
dropdown. Both are decided by Home Assistant itself — the response flag comes from
|
||||
``async_set_service_schema`` reading ``hass.services.supports_response()``, and the
|
||||
dropdown is whatever description the websocket command serves — so this file
|
||||
drives the installed HA (2026.10.1) end to end:
|
||||
|
||||
* the REAL service registry: the registration ``async_setup`` makes, its schema,
|
||||
and ``SupportsResponse.OPTIONAL`` (a *required* response would break every
|
||||
caller that does not ask for one);
|
||||
* the REAL ``get_services`` payload — the exact JSON the frontend parses — so the
|
||||
intent dropdown and the entity target picker are proven where they are consumed;
|
||||
* the REAL ``hass.services.async_call`` with ``return_response=True``: the output
|
||||
comes back under ``intent_response``, and the command that reached the fake ADB
|
||||
surface is asserted verbatim;
|
||||
* the REAL exception machinery: an unknown intent, a refused intent and ADB being
|
||||
down each raise ``ServiceValidationError`` with our translation key and a usable
|
||||
message (never a silent success, never an empty response).
|
||||
|
||||
WHAT THIS FILE CANNOT PROVE: there is no Shield and no browser here. The command
|
||||
string is asserted against the fake poller (the real ADB transport is t13/t14's
|
||||
subject), and "how the dropdown LOOKS" is the frontend's business — the wire
|
||||
contract is what this file pins. Every test skips cleanly when homeassistant is
|
||||
not installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real service registry is the point of this file",
|
||||
)
|
||||
|
||||
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.exceptions import ServiceValidationError # noqa: E402
|
||||
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
||||
from homeassistant.helpers import entity_registry as er # noqa: E402
|
||||
from homeassistant.helpers.entity_registry import ( # noqa: E402
|
||||
EntityRegistryItems,
|
||||
EntityRegistrySettings,
|
||||
)
|
||||
from homeassistant.helpers.service import ( # noqa: E402
|
||||
async_get_all_descriptions,
|
||||
)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
SERVICE = "send_intent"
|
||||
ENTITY_ID = "media_player.shield"
|
||||
|
||||
#: The registration this file sends: one component, one action, one declared
|
||||
#: variable used by the data template, and one integer with a default.
|
||||
NETFLIX_INTENT = {
|
||||
"id": "netflix_title",
|
||||
"name": "Netflix: open a title",
|
||||
"package": "com.netflix.ninja",
|
||||
"activity": "com.netflix.ninja/.MainActivity",
|
||||
"action": "android.intent.action.VIEW",
|
||||
"categories": ["android.intent.category.DEFAULT"],
|
||||
"data": "https://www.netflix.com/watch/{id}",
|
||||
"fields": [
|
||||
{"key": "id", "type": "string", "required": True},
|
||||
{"key": "resume", "type": "bool", "default": "true"},
|
||||
],
|
||||
}
|
||||
|
||||
#: What the handler must build for ``{"id": "80100172"}`` — asserted verbatim, so
|
||||
#: a change to the flag order or the quoting is a test failure, not a surprise.
|
||||
EXPECTED_COMMAND = (
|
||||
"am start -n com.netflix.ninja/.MainActivity "
|
||||
"-a android.intent.action.VIEW -c android.intent.category.DEFAULT "
|
||||
"-d https://www.netflix.com/watch/80100172 "
|
||||
"--es id 80100172 --ez resume true"
|
||||
)
|
||||
|
||||
#: What a real `am start` prints when the device ACCEPTED the intent.
|
||||
DEVICE_ANSWER = (
|
||||
"Starting: Intent { act=android.intent.action.VIEW "
|
||||
"cmp=com.netflix.ninja/.MainActivity }"
|
||||
)
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""The ADB surface the handler uses: one shell call, real values back."""
|
||||
|
||||
def __init__(self, output=DEVICE_ANSWER, error=None):
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, command):
|
||||
self.calls.append(command)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.output
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with the real registries and the real service registry.
|
||||
|
||||
Same shape as the adb_command file (t14): no core bootstrap, but the two
|
||||
containers core's loader fills are constructed the very same way, so an entity
|
||||
target expansion sees the entities the real one would.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
registry = er.async_get(hass)
|
||||
registry.entities = EntityRegistryItems(hass)
|
||||
registry.deleted_entities = {}
|
||||
registry.settings = EntityRegistrySettings()
|
||||
registry._entities_data = registry.entities.data
|
||||
hass.data[er.DATA_REGISTRY] = registry
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain=DOMAIN,
|
||||
title="Shield",
|
||||
data={},
|
||||
options={},
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
object.__setattr__(entry, "_options", {})
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
hass._shield_entry = entry
|
||||
return hass
|
||||
|
||||
|
||||
@pytest.fixture(name="integration")
|
||||
def integration_fixture():
|
||||
"""The integration package, imported by path under its real name."""
|
||||
return importlib.import_module("custom_components.nvidia_shield")
|
||||
|
||||
|
||||
@pytest.fixture(name="media_player_module")
|
||||
def media_player_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.media_player")
|
||||
|
||||
|
||||
@pytest.fixture(name="const")
|
||||
def const_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.const")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="shield")
|
||||
async def shield_fixture(ha, integration, media_player_module, const):
|
||||
"""Set the integration up with ONE Shield carrying ONE registered intent.
|
||||
|
||||
The manager is a SimpleNamespace with the ENTRY on it, because the handler
|
||||
reads the intents from the ENTRY's options — the same place the options flow
|
||||
writes them, which is what makes this a real end-to-end path.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
||||
manager = SimpleNamespace(adb_poller=poller, connected=True, entry=entry)
|
||||
media_player_module._INTENT_TARGETS[ENTITY_ID] = manager
|
||||
# the domain store a real entry setup fills, then the SAME call
|
||||
# async_setup_entry makes — so the dropdown is published exactly as in
|
||||
# production (and, when nothing is stored, comes out empty)
|
||||
ha.data.setdefault(DOMAIN, {})["shield_entry"] = manager
|
||||
integration._publish_send_intent_description(ha)
|
||||
try:
|
||||
yield poller
|
||||
finally:
|
||||
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
|
||||
ha.data.get(DOMAIN, {}).pop("shield_entry", None)
|
||||
|
||||
|
||||
def _description(descriptions):
|
||||
return descriptions[DOMAIN][SERVICE]
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
"""The websocket connection shape ``handle_get_services`` needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: list[dict] = []
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def send_result(self, msg_id, payload):
|
||||
self.messages.append({"id": msg_id, "type": "result", "result": payload})
|
||||
|
||||
|
||||
async def test_the_registration_declares_an_optional_response(ha, integration, shield):
|
||||
"""A response-bearing action must say so — OPTIONAL keeps callers free.
|
||||
|
||||
REQUIRED would make every existing automation that does not ask for a response
|
||||
fail, so this is a compatibility gate, not a nicety.
|
||||
"""
|
||||
from homeassistant.core import SupportsResponse
|
||||
|
||||
service = ha.services.async_services()[DOMAIN][SERVICE]
|
||||
|
||||
assert service.supports_response is SupportsResponse.OPTIONAL
|
||||
|
||||
|
||||
async def test_the_intent_field_is_a_dropdown_of_the_registered_names(
|
||||
ha, integration, shield
|
||||
):
|
||||
"""THE dropdown: the frontend gets the registered intents as select options.
|
||||
|
||||
The options are published from the integration's own runtime description
|
||||
(``async_set_service_schema``), because ``async_register`` has no field
|
||||
metadata parameter — the same mechanism t14 proved for the target picker.
|
||||
"""
|
||||
description = _description(await async_get_all_descriptions(ha))
|
||||
|
||||
intent_field = description["fields"]["intent"]
|
||||
assert intent_field["required"] is True
|
||||
assert intent_field["selector"]["select"]["options"] == [NETFLIX_INTENT["name"]]
|
||||
assert intent_field["selector"]["select"]["custom_value"] is True
|
||||
|
||||
# the target picker, and the response flag the UI reads
|
||||
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
assert description["response"] == {"optional": True}
|
||||
assert set(description["fields"]) == {"intent", "variables", "entity_id"}
|
||||
|
||||
# ...and the whole payload is JSON, which is what crosses the wire
|
||||
assert json.loads(json.dumps(description))["fields"]["intent"]["selector"]
|
||||
|
||||
|
||||
async def test_a_publish_with_nothing_stored_offers_no_options(ha, integration):
|
||||
"""The honest empty state: no registrations means an EMPTY dropdown.
|
||||
|
||||
The action still accepts a name (the selector keeps ``custom_value``), so the
|
||||
screen is not a dead end — it just does not invent options.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
ha.data.setdefault(DOMAIN, {}).clear()
|
||||
integration._publish_send_intent_description(ha)
|
||||
|
||||
description = _description(await async_get_all_descriptions(ha))
|
||||
|
||||
assert description["fields"]["intent"]["selector"]["select"]["options"] == []
|
||||
|
||||
|
||||
async def test_the_websocket_command_serves_the_same_dropdown(
|
||||
ha, integration, shield
|
||||
):
|
||||
"""The exact command the frontend calls, not the accessor behind it."""
|
||||
connection = RecordingConnection()
|
||||
ws_commands.handle_get_services(ha, connection, {"id": 11, "type": "get_services"})
|
||||
await ha.async_block_till_done()
|
||||
|
||||
assert len(connection.messages) == 1
|
||||
message = json.loads(connection.messages[0])
|
||||
assert message["id"] == 11
|
||||
served = message["result"][DOMAIN][SERVICE]
|
||||
assert served["fields"]["intent"]["selector"]["select"]["options"] == [
|
||||
NETFLIX_INTENT["name"]
|
||||
]
|
||||
assert served["response"] == {"optional": True}
|
||||
|
||||
|
||||
async def test_a_call_returns_the_device_s_own_output(ha, integration, shield):
|
||||
"""The whole point: the Shield's answer comes back to the caller."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"intent_response": DEVICE_ANSWER}
|
||||
assert shield.calls == [EXPECTED_COMMAND]
|
||||
|
||||
|
||||
async def test_a_caller_that_asks_for_no_response_still_sends(ha, integration, shield):
|
||||
"""A template that ignores the answer must not change the behaviour."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=False,
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert shield.calls == [EXPECTED_COMMAND]
|
||||
|
||||
|
||||
async def test_the_variables_are_typed_onto_the_command(ha, integration, shield):
|
||||
"""A declared int/bool reaches the device as its own flag, not as text."""
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172", "resume": "no"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert shield.calls == [
|
||||
EXPECTED_COMMAND.replace("--ez resume true", "--ez resume false")
|
||||
]
|
||||
|
||||
|
||||
async def test_a_required_field_left_out_names_the_field(ha, integration, shield):
|
||||
"""The action refuses BEFORE it sends: nothing travels half-specified."""
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": NETFLIX_INTENT["name"], "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "missing_intent_field"
|
||||
assert caught.value.translation_placeholders["field"] == "id"
|
||||
assert "id" in str(caught.value)
|
||||
assert shield.calls == [], "nothing was sent"
|
||||
|
||||
|
||||
async def test_an_unknown_intent_lists_the_registered_ones(ha, integration, shield):
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": "TiviMate: channel", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "unknown_intent"
|
||||
assert NETFLIX_INTENT["name"] in str(caught.value)
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_a_refused_intent_carries_the_device_s_message(ha, integration, shield):
|
||||
"""The uninstalled-package case: the device's words, and an ERROR, not "ok"."""
|
||||
shield.output = (
|
||||
"Starting: Intent { act=android.intent.action.VIEW }\n"
|
||||
"Error: Activity class {com.netflix.ninja/.MainActivity} does not exist."
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "intent_failed"
|
||||
assert "does not exist" in str(caught.value)
|
||||
assert len(shield.calls) == 1, "it was attempted exactly once, not retried"
|
||||
|
||||
|
||||
async def test_adb_down_says_the_protocol_cannot_carry_an_intent(
|
||||
ha, integration, media_player_module, const
|
||||
):
|
||||
"""The honest answer when the transport is the problem — not an empty reply."""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
||||
media_player_module._INTENT_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=poller, connected=False, entry=entry
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
finally:
|
||||
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
assert caught.value.translation_key == "intents_need_adb"
|
||||
assert "protocol" in str(caught.value)
|
||||
assert poller.calls == []
|
||||
|
||||
|
||||
async def test_the_target_is_required_and_never_guessed(ha, integration, shield):
|
||||
"""No target, and an entity that does not participate — two errors, named."""
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert caught.value.translation_key == "no_target"
|
||||
assert SERVICE in str(caught.value), "the error names the action to fix"
|
||||
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": "media_player.other",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert caught.value.translation_key == "unsupported_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_the_strings_cover_every_error_this_file_provokes():
|
||||
"""The translation keys the action raises exist in BOTH strings files.
|
||||
|
||||
A key without copy reaches the user as the raw key, so this is checked where
|
||||
the real exception is raised rather than only in the unit suite.
|
||||
"""
|
||||
shield_dir = _ROOT / "custom_components" / "nvidia_shield"
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
data = json.loads((shield_dir / name).read_text(encoding="utf-8"))
|
||||
for key in (
|
||||
"unknown_intent",
|
||||
"missing_intent_field",
|
||||
"unknown_intent_variable",
|
||||
"invalid_intent_field",
|
||||
"intent_failed",
|
||||
"intents_need_adb",
|
||||
"no_target",
|
||||
"unsupported_target",
|
||||
"ambiguous_target",
|
||||
):
|
||||
assert key in data["exceptions"], (name, key)
|
||||
for field in ("intent", "variables", "entity_id"):
|
||||
assert field in data["services"][SERVICE]["fields"], (name, field)
|
||||
@@ -234,6 +234,7 @@ def _install_stubs() -> None:
|
||||
SENSOR = "sensor"
|
||||
REMOTE = "remote"
|
||||
BUTTON = "button"
|
||||
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
|
||||
|
||||
ha_const.Platform = _Platform
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
@@ -524,7 +525,9 @@ _OPTIONS_SUBSTEP_FIELDS = {
|
||||
"power_entity",
|
||||
},
|
||||
"receiver": {"receiver_entity", "receiver_input"},
|
||||
"apps": {"app_name_map", "app_icon_map"},
|
||||
# t64: the apps table lives in its OWN step now — the Apps sub-screen is a
|
||||
# menu of row actions, so the field that owns the map is `apps_text`.
|
||||
"apps_text": {"app_name_map", "app_icon_map"},
|
||||
"scripts": {
|
||||
"remote_command_script",
|
||||
"channel_digit_delay",
|
||||
@@ -534,6 +537,16 @@ _OPTIONS_SUBSTEP_FIELDS = {
|
||||
},
|
||||
}
|
||||
|
||||
#: t64: the Apps editor's rows live one level deeper (the Apps sub-screen is a
|
||||
#: MENU of row actions), exactly as the browser navigates them.
|
||||
_OPTIONS_NESTED_STEPS = {
|
||||
"apps_text": "apps",
|
||||
"apps_add": "apps",
|
||||
"apps_edit": "apps",
|
||||
"apps_edit_form": "apps",
|
||||
"apps_remove": "apps",
|
||||
"apps_remove_confirm": "apps",
|
||||
}
|
||||
|
||||
def _options_step_of(key: str) -> str:
|
||||
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
|
||||
@@ -556,9 +569,16 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
grouped.setdefault(_options_step_of(key), {})[key] = value
|
||||
result = await flow.async_step_init(None)
|
||||
for step, sub in grouped.items():
|
||||
parent = _OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{step}")(sub)
|
||||
if save:
|
||||
# t64: a nested row returns to the APPS menu, so walk back to the main menu
|
||||
# the way the "Back to the main menu" row does before committing.
|
||||
result = await flow.async_step_menu_again()
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await flow.async_step_finish(None)
|
||||
return result
|
||||
@@ -567,6 +587,10 @@ async def _options_submit(flow, payload: dict, *, save=False):
|
||||
async def _options_open(flow, step: str):
|
||||
"""Choose an options sub-screen from the menu and open its form."""
|
||||
result = await flow.async_step_init(None)
|
||||
parent = _OPTIONS_NESTED_STEPS.get(step)
|
||||
if parent is not None:
|
||||
assert parent in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{parent}")(None)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
return await getattr(flow, f"async_step_{step}")(None)
|
||||
|
||||
@@ -682,6 +706,160 @@ class TestAppIconColumn:
|
||||
assert "app_name_map" not in flow._options # nothing half-stored
|
||||
|
||||
|
||||
class TestAppPasteRejectionNamesTheLine:
|
||||
"""t17/O3: a rejected paste says WHICH line is wrong, not just that one is.
|
||||
|
||||
The all-or-nothing rule stays (nothing is half-stored), but the message now
|
||||
quotes each offending line with its number and the reason, so a 30-row paste
|
||||
with one typo is fixed instead of bisected by hand. The row editor's own
|
||||
single-row behaviour is pinned alongside, because neither half may regress.
|
||||
"""
|
||||
|
||||
def _flow(self, tmp_path, options=None):
|
||||
module = _form_module()
|
||||
entry = SimpleNamespace(options=dict(options or {}), title="Shield")
|
||||
flow = module.NvidiaShieldOptionsFlow()
|
||||
flow.config_entry = entry # t29 G3b: HA assigns config_entry
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
return module, flow
|
||||
|
||||
async def test_the_message_names_the_numbered_bad_line(self, tmp_path):
|
||||
module, flow = self._flow(tmp_path)
|
||||
|
||||
result = await _options_submit(
|
||||
flow,
|
||||
{
|
||||
"app_name_map": (
|
||||
"ar.tvplayer.tv=TiviMate\n"
|
||||
"com.no.equals.sign\n"
|
||||
"com.netflix.ninja=Netflix"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
detail = result["description_placeholders"]["invalid_lines"]
|
||||
# the SECOND line is the bad one, and it is quoted as the user wrote it
|
||||
assert "line 2" in detail
|
||||
assert "com.no.equals.sign" in detail
|
||||
assert "no '='" in detail
|
||||
assert "line 1" not in detail and "line 3" not in detail
|
||||
assert "app_name_map" not in flow._options # still all-or-nothing
|
||||
|
||||
async def test_every_bad_line_is_named_up_to_the_cap(self, tmp_path):
|
||||
module, flow = self._flow(tmp_path)
|
||||
|
||||
result = await _options_submit(
|
||||
flow,
|
||||
{
|
||||
"app_name_map": (
|
||||
"com.good.one=Name\n"
|
||||
"com.bad.space in id=Name\n"
|
||||
"com.bad.blank=\n"
|
||||
"com.bad.icon=Name|http://x y/z.png"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
detail = result["description_placeholders"]["invalid_lines"]
|
||||
assert "line 2" in detail and "package id" in detail
|
||||
assert "line 3" in detail and "name after the '='" in detail
|
||||
assert "line 4" in detail and "icon after the '|'" in detail
|
||||
assert "line 1" not in detail # no false accusation
|
||||
|
||||
async def test_a_long_bad_paste_is_capped_and_counts_the_rest(self, tmp_path):
|
||||
module, flow = self._flow(tmp_path)
|
||||
|
||||
result = await _options_submit(
|
||||
flow,
|
||||
{"app_name_map": "\n".join(f"no-equals-{n}" for n in range(1, 8))},
|
||||
)
|
||||
|
||||
detail = result["description_placeholders"]["invalid_lines"]
|
||||
assert module.APP_MAP_PROBLEM_LIMIT == 4
|
||||
assert "line 4" in detail
|
||||
assert "line 5" not in detail
|
||||
assert "3 more line(s)" in detail, detail
|
||||
|
||||
async def test_a_blank_line_is_not_a_row_and_never_a_problem(self, tmp_path):
|
||||
module, flow = self._flow(tmp_path)
|
||||
|
||||
await _options_submit(
|
||||
flow, {"app_name_map": "\n\nar.tvplayer.tv=TiviMate\n\n"}
|
||||
)
|
||||
|
||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||
|
||||
async def test_a_good_paste_clears_the_previous_rejection(self, tmp_path):
|
||||
module, flow = self._flow(tmp_path)
|
||||
await _options_submit(flow, {"app_name_map": "com.bad row=Name"})
|
||||
assert flow._app_map_problems
|
||||
|
||||
result = await _options_submit(flow, {"app_name_map": "com.good.one=Name"})
|
||||
|
||||
assert result["type"] == "menu" # back at the Apps screen, nothing left over
|
||||
assert flow._app_map_problems == ""
|
||||
assert flow._options_dict["app_name_map"] == {"com.good.one": "Name"}
|
||||
|
||||
async def test_the_parser_reports_problems_in_a_pure_call(self):
|
||||
module = _form_module()
|
||||
|
||||
app_map, problems = module._app_map_parse("com.a=One\nbroken\ncom.b=", {})
|
||||
|
||||
assert app_map is None
|
||||
assert [number for number, _line, _why in problems] == [2, 3]
|
||||
assert problems[0][1] == "broken"
|
||||
assert "no '='" in problems[0][2][0]
|
||||
assert "name after the '='" in problems[1][2][0]
|
||||
# and a valid paste reports nothing at all
|
||||
assert module._app_map_parse("com.a=One", {}) == ({"com.a": "One"}, [])
|
||||
|
||||
async def test_the_report_and_the_wrapper_cannot_disagree(self):
|
||||
module = _form_module()
|
||||
icons: dict[str, str] = {}
|
||||
|
||||
assert module._app_map_from_text("com.a=One|http://x/i.png", icons) == {
|
||||
"com.a": "One"
|
||||
}
|
||||
assert icons == {"com.a": "http://x/i.png"}
|
||||
assert module._app_map_from_text("com.bad row=One", {}) is None
|
||||
|
||||
def test_the_paste_report_and_the_row_editor_apply_the_same_rule(self):
|
||||
"""t17/O3 rider: the one-row path is unchanged, and both paths agree.
|
||||
|
||||
The row editor is driven in tests/test_config_flow_ux.py
|
||||
(``test_an_invalid_row_is_refused_and_stores_nothing``); what matters HERE
|
||||
is that the new per-line report cannot reject a row the editor accepts (or
|
||||
wave one through that the editor refuses) — otherwise a paste and a typed
|
||||
row would disagree about the same text.
|
||||
"""
|
||||
module = _form_module()
|
||||
rows = [
|
||||
("com.a", "Alpha", "", True),
|
||||
("com.a", "Alpha", "http://x/i.png", True),
|
||||
("com.bad row", "Alpha", "", False),
|
||||
("", "Alpha", "", False),
|
||||
("com.a", "", "", False),
|
||||
("com.a", "Alpha", "http://x y/i.png", False),
|
||||
]
|
||||
|
||||
for package, name, icon, valid in rows:
|
||||
assert module._valid_app_row(package, name, icon) is valid, (package, name)
|
||||
assert bool(module._app_row_problems(package, name, icon)) is not valid, (
|
||||
package,
|
||||
name,
|
||||
icon,
|
||||
)
|
||||
|
||||
# the paste path STRIPS before checking, which is why a whitespace-only
|
||||
# name cannot get in through it either (t49: nothing blank is stored)
|
||||
app_map, problems = module._app_map_parse("com.a= ", {})
|
||||
assert app_map is None
|
||||
assert problems[0][2] == ["the name after the '=' is empty"]
|
||||
|
||||
|
||||
class TestChannelEntryOptions:
|
||||
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||
|
||||
@@ -1309,7 +1487,7 @@ class TestAppsMapOptionsEditor:
|
||||
|
||||
# first visit: the form carries the serialized current map as the
|
||||
# field default — NEVER the dict (that is the [object Object] bug)
|
||||
result = await _options_open(flow, "apps")
|
||||
result = await _options_open(flow, "apps_text")
|
||||
|
||||
assert result["type"] == "form"
|
||||
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
||||
@@ -1336,7 +1514,12 @@ class TestAppNameMapResolution:
|
||||
assert player.app_name == "TiviMate"
|
||||
assert player.app_id == "ar.tvplayer.tv" # app_id stays raw
|
||||
|
||||
async def test_unmapped_package_stays_raw(self):
|
||||
async def test_a_curated_package_reads_by_its_name(self):
|
||||
"""t66: an app the user did NOT map still gets a name — from the curated table.
|
||||
|
||||
This is the device the report describes: `pm dump` names nothing, and a
|
||||
package absent from the user's own map used to read as its raw id.
|
||||
"""
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
@@ -1347,7 +1530,21 @@ class TestAppNameMapResolution:
|
||||
manager._on_current_app("com.google.android.youtube.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert player.app_name == "com.google.android.youtube.tv"
|
||||
assert player.app_name == "YouTube"
|
||||
|
||||
async def test_an_unmapped_uncurated_package_stays_raw(self):
|
||||
"""The chain's last tier, on the same surface: nothing names it → the id."""
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("com.example.nobody")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert player.app_name == "com.example.nobody"
|
||||
|
||||
async def test_cached_manifest_label_is_the_second_tier(self):
|
||||
"""P5-1: below the explicit map sits the device manifest label."""
|
||||
|
||||
Reference in new issue
Block a user