Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7540e6f4 | ||
|
|
5ae2baa618 | ||
|
|
181a51af95 | ||
|
|
431b7cd8f4 | ||
|
|
c884c7e1ab | ||
|
|
afc3326fc3 | ||
|
|
1c414c2f70 | ||
|
|
93d0afcdcf | ||
|
|
0ef72a1fbb | ||
|
|
c05e6fa929 | ||
|
|
ef42682250 | ||
|
|
ff8dd1c454 | ||
|
|
364d4b7da3 | ||
|
|
95d8d20571 | ||
|
|
3a55519b74 | ||
|
|
5eefd0c6d0 | ||
|
|
a56ec38170 | ||
|
|
ee3f2845af | ||
|
|
9461d69469 | ||
|
|
ca3fdec218 | ||
|
|
b0adbd2dfd | ||
|
|
cd8f528a27 | ||
|
|
74e0549196 | ||
|
|
85e96f3c60 | ||
|
|
0c7e405108 | ||
|
|
ccde3cff71 | ||
|
|
07682ff082 |
No files matched your search
+109
-4
@@ -7,10 +7,11 @@
|
||||
# firmware captures live in tests/fixtures
|
||||
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
|
||||
#
|
||||
# The `ha-api-drift` job is the ONE place Home Assistant is installed: every
|
||||
# other job validates our code against hand-written stubs, which stay green
|
||||
# even when a call site no longer matches the real API. That is how three
|
||||
# drifts reached master (t36 D1/D2/D3) — see tests/ha_api_drift.py.
|
||||
# Home Assistant is installed in exactly two jobs: `ha-api-drift` (below) and
|
||||
# `real-ha-config-flow`. Both exist because every other job validates our code
|
||||
# against hand-written stubs, which stay green even when a call site no longer
|
||||
# matches the real API. That is how three drifts reached master (t36 D1/D2/D3) —
|
||||
# see tests/ha_api_drift.py.
|
||||
|
||||
name: Test
|
||||
|
||||
@@ -112,3 +113,107 @@ 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:
|
||||
#
|
||||
# * 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.
|
||||
#
|
||||
# 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.
|
||||
real-ha-config-flow:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Provision the interpreter Home Assistant needs
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
# Same floor as the ha-api-drift job: Home Assistant 2026.10.1
|
||||
# declares Requires-Python >= 3.14.2 (tests/ha_api_drift.py holds our
|
||||
# copy of that number). This resolves the newest 3.14.x patch.
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Ensure uv is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# uvx runs the exact command this gate was validated with locally;
|
||||
# runner images are not guaranteed to ship it.
|
||||
if ! command -v uvx >/dev/null 2>&1; then
|
||||
python3 -m pip install --quiet uv \
|
||||
|| python3 -m pip install --quiet --break-system-packages uv
|
||||
fi
|
||||
uvx --version
|
||||
|
||||
- name: Check the interpreter satisfies Home Assistant's floor
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The floor comes from tests/ha_api_drift.py — one source of truth for
|
||||
# this guard and the metadata pin in tests/test_ha_api_drift.py. uvx
|
||||
# resolves the same interpreter the test step will use, so a too-old
|
||||
# interpreter fails here rather than halfway through the install.
|
||||
uvx --python 3.14 python - <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path("tests").resolve()))
|
||||
from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402
|
||||
|
||||
needed = ".".join(str(part) for part in FLOOR)
|
||||
if sys.version_info < FLOOR:
|
||||
print(
|
||||
f"Home Assistant needs Python >= {needed}, but this job runs "
|
||||
f"{sys.version.split()[0]}."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
|
||||
PY
|
||||
|
||||
- name: Real-HA config-flow tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG=/tmp/real-ha-config-flow.log
|
||||
# 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"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::the real-HA config-flow 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.
|
||||
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."
|
||||
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)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -63,22 +63,45 @@ remote protocol is the fast push/command path:
|
||||
|
||||
1. **Plug off** — the power switch says `off` → `off`.
|
||||
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
||||
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
|
||||
4. **Intent** — a just-sent command's target state, self-healing against
|
||||
3. **Intent** — a just-sent command's target state, self-healing against
|
||||
reality within 30 s (retry goes over the transport that served it).
|
||||
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||
6. **ADB media session** — `playing` / `paused` for apps that publish a
|
||||
session (foreground app preferred).
|
||||
7. **Foreground fallback** — an app is open (`current_app` protocol push,
|
||||
then the ADB `mResumedActivity` parse) but publishes no media session →
|
||||
`on`. Its identity comes from the TTL-capped last-known-state cache, the
|
||||
last channel entered, or the zero-cost "audio playback" hint
|
||||
(`media recently active`) — never resurrecting `playing` past the 60 s
|
||||
state TTL.
|
||||
8. **On** — powered, awake, nothing more specific.
|
||||
4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||
5. **ADB media session** — `playing` / `paused` / `buffering` for apps that
|
||||
publish a session (foreground app preferred). `buffering` is the media
|
||||
session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the
|
||||
stock-Android buffering value, **inferred** from that line shape: no captured
|
||||
fixture here contains `state=6` and no real device was available. A buffering
|
||||
session advertises the playback controls exactly like `playing`.
|
||||
6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the
|
||||
power plug on) and nothing is playing, or ADB reports it asleep (standby).
|
||||
A powered Shield with an app open and nothing playing reads `idle`.
|
||||
7. **On** — **no source reported a power or playback fact** (t58 UNIT 0; the
|
||||
earlier wording said "no data", which was too loose). `on` is the "we cannot
|
||||
know" state and the only situation it describes, so every source that
|
||||
*answers* — including an answer of "not playing" — stops the ladder at
|
||||
`idle` at worst, never here:
|
||||
- a configured **Jellyfin** entity in **any** state: `idle`, `off`,
|
||||
`standby` and `unavailable` all mean "its session is not playing", so they
|
||||
report — and because the ladder keeps going, a silent Jellyfin still
|
||||
cannot mask a live media session below it;
|
||||
- a **live media session with no playback flag** — the app publishes a
|
||||
session but is not playing, paused or buffering;
|
||||
- a **bare foreground app** — the protocol's `current_app` push or the ADB
|
||||
`mResumedActivity` parse answered;
|
||||
- a configured **power entity**, whatever it currently says;
|
||||
- protocol `is_on` / ADB awake, plugged or not.
|
||||
`on` survives only for an entry where none of those exists, and it never
|
||||
masks a `playing`/`paused`/`buffering` source.
|
||||
|
||||
The foreground tier is what keeps an app's identity visible while the state is
|
||||
`idle`: the `current_app` protocol push (then the ADB `mResumedActivity`
|
||||
parse), with its title from the TTL-capped last-known-state cache, the last
|
||||
channel entered, or the zero-cost "audio playback" hint
|
||||
(`media recently active`) — never resurrecting `playing` past the 60 s state
|
||||
TTL.
|
||||
|
||||
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
||||
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
|
||||
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 5–6 plus
|
||||
the audio hint are what keep it visible.
|
||||
|
||||
**Standalone setup** offers both state sources at once — the Nvidia Remote
|
||||
@@ -174,8 +197,8 @@ 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` / `idle` / `off`) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
|
||||
| `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_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 |
|
||||
@@ -281,8 +304,66 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
|
||||
|
||||
### Options
|
||||
|
||||
The Configure page is a **menu of sub-screens**, not one long form. Home Assistant
|
||||
renders it as a list of rows; each row opens a short form and comes back to the menu,
|
||||
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 |
|
||||
| **Receiver** | the receiver/soundbar entity and the input it uses |
|
||||
| **Apps** | the apps table — one row per app (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 |
|
||||
|
||||
Every field that used to be on the single page is still there, under one of those
|
||||
rows. Edits are merged **per key**, so visiting another sub-screen (or saving from a
|
||||
later one) never drops what an earlier one changed. Half-finished pages are not
|
||||
written: closing the dialog without **Save and close** leaves the entry as it was.
|
||||
|
||||
The **setup** flow follows the same shape: its first form is unchanged (name, host,
|
||||
transports, the linked entities), and after it the flow is a menu — **Receiver**,
|
||||
**Connection**, **Set up ADB**, **Pair the remote protocol**, **Create the entry** — so
|
||||
anything you want to correct is one row away instead of buried in a form you have
|
||||
already submitted. The two pairing rows appear only while that transport still needs
|
||||
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.
|
||||
|
||||
### The Apps table
|
||||
|
||||
The **Apps** row is an editable table. One app per line, in the documented format:
|
||||
|
||||
```
|
||||
ar.tvplayer.tv=TiviMate
|
||||
com.plex.hdx=Plex|http://192.168.1.10:9000/plex.png
|
||||
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 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.
|
||||
|
||||
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
|
||||
that switch is on**: `turn_on`, `play_media` and `select_source` call
|
||||
`switch.turn_on` when — and only when — it reports `off`, and touch nothing else
|
||||
about it (never volume, never a toggle). An already-on switch gets no call at all,
|
||||
a switch whose state cannot be read (`unavailable`/`unknown`) is left alone rather
|
||||
than guessed at, and a receiver that is off while the Shield is idle stays off.
|
||||
That is what makes receiver **volume scripts unnecessary** for this shape: there is
|
||||
no volume to script, only an on/off state to ensure. A `media_player.*` receiver
|
||||
keeps its own behaviour — input selection and attribute enrichment.
|
||||
|
||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||
- **Receiver + Input** — for media-zone coordination
|
||||
- **Receiver + Input** — for media-zone coordination; `media_player` or `switch`
|
||||
- **Power Switch** — smart plug entity
|
||||
- **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
|
||||
transport on or off
|
||||
@@ -358,12 +439,86 @@ A script that fails surfaces as a translated error on the service call that trig
|
||||
it (the command is not retried over protocol/ADB), because a silent no-op is the one
|
||||
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:
|
||||
|
||||
| Command | What it returns | How it is treated |
|
||||
| --- | --- | --- |
|
||||
| `pm list packages -3` | every **third-party** (user-installed) package, one `package:<id>` per line | all of them become rows |
|
||||
| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended |
|
||||
|
||||
System apps are **not** enumerated — a Shield ships a few hundred of them and they are
|
||||
not launchable apps — with the one exception that matters: the active launcher, which
|
||||
is a system app yet is exactly what the user sees. Nothing else is filtered, so an app
|
||||
is missing only if Android itself does not report it.
|
||||
|
||||
*(Every output shape above is the **documented** Android one, not an observed
|
||||
capture: no real Shield was available when this was written, so
|
||||
`pm list packages -3`, `resolve-activity --brief` and `pm dump` are exercised from
|
||||
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.
|
||||
|
||||
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
|
||||
there is no device icon to read. Each app gets a curated `package → mdi:` icon where we
|
||||
know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin —
|
||||
and `mdi:television` otherwise. The icon never enters your **icon column**: that column
|
||||
is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an
|
||||
`mdi:` name there would put a value the media player cannot use into your own map.
|
||||
|
||||
Discovery does not run on the watchdog: the Configure row runs it, the dashboard
|
||||
sensor refreshes the table when it has no fresh one, and the result is cached for 6 h.
|
||||
With ADB off, or unreachable, the table simply shows the rows you wrote yourself and
|
||||
the menu says why. A sweep that **could not run** is not cached as an answer: it is
|
||||
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.
|
||||
|
||||
**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`,
|
||||
`not_connected` or `adb_disabled`: `ok` means the table IS the answer — including a
|
||||
genuinely empty one — and `unknown` means the last sweep could not run (an empty list
|
||||
then says nothing about the device, and the next refresh retries). It only exists for
|
||||
ADB-enabled entries, and it is cheap to refresh — `homeassistant.update_entity` on it
|
||||
re-runs the sweep only when there is no fresh table (the cache expired, or the last
|
||||
sweep could not run), while the Configure row forces one.
|
||||
|
||||
```yaml
|
||||
# every installed app, with the name this integration would show
|
||||
{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }}
|
||||
| map(attribute='name') | list }}
|
||||
```
|
||||
|
||||
### Sources (the app list in a dropdown)
|
||||
|
||||
The media player's source list is your **Apps** map — the same map the Browse tree and
|
||||
app-name resolution use. With no map configured it falls back to a small built-in list
|
||||
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:
|
||||
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.
|
||||
|
||||
**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
|
||||
this entity (the device name, no rows) instead of a second, inconsistent copy of the
|
||||
same list — the user report was *"Media Browser shows Apps. To me it should probably be
|
||||
integrated with the home assistant media display, keep select sources for apps"*.
|
||||
Everything an app row could do lives in `source_list` and `media_player.select_source`
|
||||
below, and nothing else on this entity is browsable, so there is no dead entry to click
|
||||
and nothing else the browser could show.
|
||||
|
||||
```yaml
|
||||
# a media-player card, or anything that reads source_list
|
||||
@@ -377,7 +532,7 @@ entity: media_player.living_room_shield
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
- **`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
|
||||
is what the Browse tree publishes), names match case-insensitively, and a name that
|
||||
holds the package id), names match case-insensitively, and a name that
|
||||
is not in the list is an error rather than a silent no-op:
|
||||
|
||||
```yaml
|
||||
@@ -398,10 +553,9 @@ The map is your own override, so two shapes are accepted rather than papered ove
|
||||
|
||||
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
|
||||
row — the first spelling — because name matching is case-insensitive. The shadowed
|
||||
app is not lost: the Browse tree still lists it under its own package id, and
|
||||
`media_player.select_source` accepts that id.
|
||||
app is not lost: `media_player.select_source` accepts its own package id.
|
||||
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||
dropdown and the Browse tree, but `app_name`, `source` and the Current App sensor
|
||||
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.
|
||||
|
||||
|
||||
@@ -130,6 +130,84 @@ def _app_map_from_text(
|
||||
return app_map
|
||||
|
||||
|
||||
def _apps_table_text(
|
||||
stored_map: dict, stored_icons: dict, discovered: list[dict]
|
||||
) -> str:
|
||||
"""The Editable apps table: stored rows first, then discovered apps (t57).
|
||||
|
||||
Every app the Shield reports gets a row, so the page opens on the REAL app
|
||||
list instead of the four curated entries ("it doesn't find all the apps").
|
||||
The stored rows keep their position, name and icon — the user's edit is
|
||||
never overwritten by a discovery — and a discovered app the map does not
|
||||
mention is appended with the device label (or its package id when the label
|
||||
fetch did not answer) and its heuristic icon.
|
||||
|
||||
The icon column is emitted only for a stored icon, because the discovered
|
||||
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
|
||||
values are a picture URL or a ``camera.*`` entity that feeds
|
||||
``entity_picture``. Writing an ``mdi:`` string there would put a value the
|
||||
media player cannot use into the user's own map — the icon is offered in the
|
||||
table's description instead, and in the installed-apps sensor's attributes.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for package, name in (stored_map or {}).items():
|
||||
seen.add(package)
|
||||
icon = (stored_icons or {}).get(package)
|
||||
lines.append(f"{package}={name}|{icon}" if icon else f"{package}={name}")
|
||||
for app in discovered or []:
|
||||
package = app.get("package")
|
||||
if not package or package in seen:
|
||||
continue
|
||||
seen.add(package)
|
||||
name = app.get("name") or package
|
||||
lines.append(f"{package}={name}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _options_field_values(options: dict | None) -> dict:
|
||||
"""Entry options as FRONTEND-SERIALIZABLE field values (t56).
|
||||
|
||||
The bug this exists to kill: the apps map is stored as a DICT, and the
|
||||
options page handed that dict straight to ``add_suggested_values_to_schema``
|
||||
as the suggested value of a TEXT field. Real Home Assistant serializes the
|
||||
suggested value verbatim into the form the frontend receives (verified on a
|
||||
real install: ``{"suggested_value": {"ar.tvplayer.tv": "TiviMate"}}`` for a
|
||||
``type: string`` field), and the browser renders a non-string as
|
||||
``[object Object]``.
|
||||
|
||||
So the suggestion layer gets only what a text field can display: the apps
|
||||
map (plus its icon column) serialized to the documented
|
||||
``package=name[|icon]`` lines, and every other option value unchanged.
|
||||
"""
|
||||
options = dict(options or {})
|
||||
app_map = options.pop(CONF_APP_NAME_MAP, None)
|
||||
icon_map = options.pop(CONF_APP_ICON_MAP, None)
|
||||
if isinstance(app_map, dict):
|
||||
options[CONF_APP_NAME_MAP] = _app_map_to_text(
|
||||
app_map, icon_map if isinstance(icon_map, dict) else None
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _app_map_submission(value) -> tuple[dict, dict] | None:
|
||||
"""Parse the apps-table field into ``(app_map, icon_map)``; None if invalid.
|
||||
|
||||
A blank field is the documented way to clear the map; anything else must
|
||||
parse as ``package=name[|icon]`` lines or the whole submission is rejected
|
||||
(never half-stored).
|
||||
"""
|
||||
if value is None:
|
||||
return {}, {}
|
||||
if not str(value).strip():
|
||||
return {}, {}
|
||||
icons: dict[str, str] = {}
|
||||
app_map = _app_map_from_text(str(value), icons)
|
||||
if app_map is None:
|
||||
return None
|
||||
return app_map, icons
|
||||
|
||||
|
||||
def _host_field_key(field: str, host: str):
|
||||
"""The host-field KEY, carrying the known address as its default.
|
||||
|
||||
@@ -208,8 +286,13 @@ def _build_selectors():
|
||||
"media_player": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain="media_player")
|
||||
),
|
||||
# t56: a receiver is a media_player (an AVR/soundbar) OR a plain switch
|
||||
# (a soundbar/speaker that only knows on and off). The device_class
|
||||
# constraint belongs to the media_player half only, so a switch is not
|
||||
# filtered out by it — and the switch behaviour itself (turning it on
|
||||
# during playback) lives in the media_player task that follows.
|
||||
"receiver": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
||||
selector.EntitySelectorConfig(domain=["media_player", "switch"])
|
||||
),
|
||||
"power": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
||||
@@ -289,14 +372,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
|
||||
}
|
||||
|
||||
# If ADB is enabled, go to pairing step
|
||||
if self._user_data[CONF_ADB_ENABLED]:
|
||||
return await self.async_step_adb_pair()
|
||||
|
||||
if self._user_data[CONF_PROTOCOL_ENABLED]:
|
||||
return await self.async_step_protocol_pair()
|
||||
|
||||
return self._create_entry()
|
||||
# t56: the first form stays exactly one screen (so nothing it used
|
||||
# to accept moves or disappears), and the flow then becomes a MENU
|
||||
# of sub-screens — Connection / Receiver / the pairing steps /
|
||||
# Finish — instead of a single long page or a hidden sequence.
|
||||
return await self.async_step_menu()
|
||||
|
||||
return self._show_user_form(errors)
|
||||
|
||||
@@ -404,15 +484,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_user()
|
||||
|
||||
def _create_entry(self):
|
||||
"""Create the config entry from stored user data.
|
||||
"""Create the config entry from stored user data + setup sub-screens.
|
||||
|
||||
Everything the integration needs lives in the OPTIONS (t41: one host
|
||||
field among them); ``data`` stays empty because no value has to be
|
||||
immutable.
|
||||
immutable. The setup sub-screens (t56) contribute ``_setup_extras`` —
|
||||
disjoint keys merged over the first form, so a value configured on a
|
||||
sub-screen wins over the same key left blank on the first form.
|
||||
"""
|
||||
d = self._user_data
|
||||
d = {**self._user_data, **self._setup_extras}
|
||||
return self.async_create_entry(
|
||||
title=d["name"],
|
||||
# the name is the entry title; a flow driven programmatically (and
|
||||
# the first form always carries it) may omit it, so fall back
|
||||
title=d.get("name") or DEFAULT_NAME,
|
||||
data={},
|
||||
options={
|
||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||
@@ -428,11 +512,18 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _async_after_pairing(self):
|
||||
"""Route after the pairing steps: protocol next, else create the entry."""
|
||||
if self._user_data.get(CONF_PROTOCOL_ENABLED):
|
||||
return await self.async_step_protocol_pair()
|
||||
return self._create_entry()
|
||||
"""Route after a pairing step: the protocol next, else the setup menu (t56).
|
||||
|
||||
The "ADB paired, now the protocol" sequence is deliberate and stays
|
||||
automatic — a user who asked for both transports should not have to find
|
||||
the second one in a menu — and the setup menu afterwards is where the
|
||||
remaining sub-screens (Connection / Receiver) and Finish live.
|
||||
"""
|
||||
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
||||
self, "_paired_protocol", False
|
||||
):
|
||||
return await self.async_step_protocol_pair()
|
||||
return self._show_setup_menu()
|
||||
|
||||
def _discard_protocol_client(self) -> None:
|
||||
"""Drop a pairing client whose session became void (host corrected)."""
|
||||
@@ -607,7 +698,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self._finish_reauth()
|
||||
if self._reconfigure_entry is not None:
|
||||
return self._finish_reconfigure(mac)
|
||||
return self._create_entry()
|
||||
# t56: the protocol is paired now, so the setup menu must not offer to
|
||||
# pair it again — it returns to the menu (Receiver / Connection /
|
||||
# Finish) instead of creating the entry here.
|
||||
self._paired_protocol = True
|
||||
return self._show_setup_menu()
|
||||
|
||||
async def async_step_import(self, data):
|
||||
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
||||
@@ -765,6 +860,192 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
# ── t56: the setup menu and its sub-screens ──
|
||||
|
||||
@property
|
||||
def _setup_extras(self) -> dict:
|
||||
"""Options collected on the setup sub-screens (t56), merged, not last-writer-wins.
|
||||
|
||||
Lazily created so the flow's ``__init__`` stays untouched; the menu's
|
||||
sub-screens each own a disjoint set of keys and merge into this dict, so
|
||||
configuring the receiver and then the connection (in either order)
|
||||
cannot drop the other's edits.
|
||||
"""
|
||||
if getattr(self, "_setup_extra_options", None) is None:
|
||||
self._setup_extra_options = {}
|
||||
return self._setup_extra_options
|
||||
|
||||
def _show_setup_menu(self):
|
||||
"""The setup menu: receiver, connection, pairing, finish (t56).
|
||||
|
||||
Home Assistant's standard menu step: it renders every option as a row and
|
||||
dispatches the choice to ``async_step_<row id>``.
|
||||
|
||||
TWO rules this menu has to obey, both learned the hard way:
|
||||
|
||||
* the row id must BE its handler name — ``receiver``,
|
||||
``connection``, ``adb_pair``, ``protocol_pair``, ``finish`` — because
|
||||
that is literally how HA dispatches (``_raise_if_step_does_not_exist``
|
||||
on ``async_step_<row id>``). A friendlier alias like ``adb`` is an
|
||||
UnknownStep.
|
||||
* a sub-screen's rendered ``step_id`` must be the SAME string, because a
|
||||
submitted form is dispatched by its own step_id. Rendering
|
||||
``setup_receiver`` from the ``receiver`` row makes the form
|
||||
unsubmittable.
|
||||
|
||||
``async_show_menu`` takes no ``errors`` (verified against HA 2026.10.1:
|
||||
``step_id``/``menu_options``/``sort``/``description_placeholders``
|
||||
only), which is why this method takes none either.
|
||||
|
||||
Both transports stay optional (the pairing steps keep their skip), so
|
||||
the user can finish at any point. A transport that already paired is not
|
||||
offered again — that row would re-open a pairing session the Shield no
|
||||
longer needs (file an entry option to change it).
|
||||
"""
|
||||
# built in one explicit order rather than a chain of insert(0), which
|
||||
# is how this read backwards until real HA showed the row order
|
||||
options = ["receiver", "connection"]
|
||||
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
|
||||
self, "_paired_adb", False
|
||||
):
|
||||
options.append("adb_pair")
|
||||
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
||||
self, "_paired_protocol", False
|
||||
):
|
||||
options.append("protocol_pair")
|
||||
options.append("finish")
|
||||
return self.async_show_menu(
|
||||
step_id="menu",
|
||||
menu_options=options,
|
||||
description_placeholders=self._setup_menu_placeholders(),
|
||||
)
|
||||
|
||||
def _setup_menu_placeholders(self) -> dict:
|
||||
"""What the setup menu says about the transports it still offers."""
|
||||
pending = []
|
||||
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
|
||||
self, "_paired_adb", False
|
||||
):
|
||||
pending.append("ADB")
|
||||
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
|
||||
self, "_paired_protocol", False
|
||||
):
|
||||
pending.append("the remote protocol")
|
||||
if not pending:
|
||||
return {"pairing": "everything that needs pairing is paired"}
|
||||
return {"pairing": "still to pair: " + " and ".join(pending)}
|
||||
|
||||
async def async_step_menu(self, user_input=None):
|
||||
"""First visit: open the pending pairing; later visits: show the menu.
|
||||
|
||||
Deliberately NOT the menu's ``finish`` row (that dispatches to
|
||||
``async_step_finish``, a different method). The menu's own option ids
|
||||
are therefore ``['connection', 'receiver', 'adb'?, 'protocol'?,
|
||||
'finish']`` and never include ``menu`` itself, so the flow manager can
|
||||
never bounce back into this method — the one shape that would loop.
|
||||
|
||||
``user_input`` is never a menu choice here either: the flow manager
|
||||
reads ``next_step_id`` off the MENU result and calls the chosen step
|
||||
directly, then that step comes back here to re-render.
|
||||
"""
|
||||
if not getattr(self, "_pairing_underway", False):
|
||||
self._pairing_underway = True
|
||||
if self._user_data.get(CONF_ADB_ENABLED):
|
||||
return await self.async_step_adb_pair()
|
||||
if self._user_data.get(CONF_PROTOCOL_ENABLED):
|
||||
return await self.async_step_protocol_pair()
|
||||
return self._show_setup_menu()
|
||||
|
||||
def _finish_setup(self):
|
||||
"""Create the entry — the ``finish`` row and the no-transport path."""
|
||||
return self._create_entry()
|
||||
|
||||
def _apply_setup_submission(self, user_input: dict) -> None:
|
||||
"""Merge a sub-screen submission into the flow's pending options (t56).
|
||||
|
||||
ONE rule for every field, so "cleared" and "untouched" can never be
|
||||
confused: a key the submission CARRIES is authoritative — an empty
|
||||
value clears the setting — and a key it does NOT carry is untouched and
|
||||
keeps its stored value. The frontend submits every rendered field, so a
|
||||
missing key only ever means "that field was not on this screen".
|
||||
"""
|
||||
for key, value in user_input.items():
|
||||
if value is None:
|
||||
# an explicit null means the same as an empty value: clear
|
||||
self._setup_extras[key] = ""
|
||||
else:
|
||||
self._setup_extras[key] = value
|
||||
|
||||
async def async_step_receiver(self, user_input=None):
|
||||
"""Setup sub-screen: the receiver/soundbar this Shield plays through.
|
||||
|
||||
Accepts a media_player OR a plain switch (t56 string/selector change) —
|
||||
the switch behaviour itself lands with the media_player work that
|
||||
follows.
|
||||
"""
|
||||
if user_input is not None:
|
||||
self._apply_setup_submission(user_input)
|
||||
return await self.async_step_menu()
|
||||
|
||||
sel = _build_selectors()
|
||||
receiver = self._setup_extras.get(CONF_RECEIVER_ENTITY, "")
|
||||
fields: dict = {}
|
||||
if receiver:
|
||||
fields[vol.Optional(CONF_RECEIVER_ENTITY, default=receiver)] = sel["receiver"]
|
||||
else:
|
||||
fields[vol.Optional(CONF_RECEIVER_ENTITY)] = sel["receiver"]
|
||||
current_input = self._setup_extras.get(CONF_RECEIVER_INPUT, "")
|
||||
if current_input:
|
||||
fields[vol.Optional(CONF_RECEIVER_INPUT, default=current_input)] = str
|
||||
else:
|
||||
fields[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||
# the step_id IS the menu row id: HA dispatches a submitted form by its
|
||||
# own step_id, so anything else is an UnknownStep on submit
|
||||
return self.async_show_form(step_id="receiver", data_schema=vol.Schema(fields))
|
||||
|
||||
async def async_step_connection(self, user_input=None):
|
||||
"""Setup sub-screen: the address, the ADB port and the two transports.
|
||||
|
||||
The same three settings the first form carries, re-editable together —
|
||||
the first form is one screen by design (so nothing it used to accept can
|
||||
disappear), and this menu entry is the way back to the address after a
|
||||
pairing attempt failed. Merged like every other sub-screen.
|
||||
"""
|
||||
if user_input is not None:
|
||||
self._apply_setup_submission(user_input)
|
||||
if CONF_HOST in user_input:
|
||||
self._user_data[CONF_HOST] = str(user_input.get(CONF_HOST) or "").strip()
|
||||
if CONF_ADB_PORT in user_input:
|
||||
try:
|
||||
self._user_data[CONF_ADB_PORT] = int(user_input[CONF_ADB_PORT])
|
||||
except (TypeError, ValueError):
|
||||
self._user_data[CONF_ADB_PORT] = DEFAULT_ADB_PORT
|
||||
for key in (CONF_ADB_ENABLED, CONF_PROTOCOL_ENABLED):
|
||||
if key in user_input:
|
||||
self._user_data[key] = bool(user_input[key])
|
||||
return await self.async_step_menu()
|
||||
|
||||
host = self._host()
|
||||
port = self._user_data.get(CONF_ADB_PORT)
|
||||
if not isinstance(port, int):
|
||||
port = DEFAULT_ADB_PORT
|
||||
fields = {
|
||||
_host_field_key(CONF_HOST, host): str,
|
||||
vol.Optional(CONF_ADB_PORT, default=port): int,
|
||||
vol.Optional(
|
||||
CONF_ADB_ENABLED, default=bool(self._user_data.get(CONF_ADB_ENABLED, True))
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
default=bool(self._user_data.get(CONF_PROTOCOL_ENABLED, True)),
|
||||
): bool,
|
||||
}
|
||||
return self.async_show_form(step_id="connection", data_schema=vol.Schema(fields))
|
||||
|
||||
async def async_step_finish(self, user_input=None):
|
||||
"""The menu's finish row: create the entry from the first form + sub-screens."""
|
||||
return self._finish_setup()
|
||||
|
||||
def _show_adb_pair_form(self, errors=None):
|
||||
"""adb_pair form: the address/port (fixable here) and the skip escape.
|
||||
|
||||
@@ -805,7 +1086,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# state when no key exists, and the Pair ADB button (only
|
||||
# registered for adb-enabled entries) pairs later without
|
||||
# re-adding the integration.
|
||||
return await self._async_after_pairing()
|
||||
#
|
||||
# t56: a skip means the ADB attempt is DECIDED, so the setup
|
||||
# menu takes over from here — the protocol is still offered on
|
||||
# it (unpaired), and the user reaches Connection/Receiver/Finish
|
||||
# without being marched through another form.
|
||||
return self._show_setup_menu()
|
||||
|
||||
typed_host = str(user_input.get(CONF_HOST) or "").strip()
|
||||
if typed_host:
|
||||
@@ -831,6 +1117,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
host, port, key_path
|
||||
)
|
||||
if connected:
|
||||
# t57 F2: paired, so the menu must stop offering that row —
|
||||
# the same rule the protocol row already follows
|
||||
self._paired_adb = True
|
||||
return await self._async_after_pairing()
|
||||
else:
|
||||
errors["base"] = "adb_failed"
|
||||
@@ -844,13 +1133,24 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
|
||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||||
"""Handle options for Nvidia Shield — a menu of sub-screens (t29 G3b, t56).
|
||||
|
||||
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||||
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||||
deprecated). Suggested values come through
|
||||
``add_suggested_values_to_schema``, and saving reloads via the
|
||||
OptionsFlowWithReload base — no listener boomerangs.
|
||||
The constructor takes NO arguments: HA assigns ``self.config_entry`` on
|
||||
instantiation (the pre-2026 ``OptionsFlow(config_entry)`` pattern is
|
||||
deprecated) and resolves it through ``hass.config_entries`` at call time.
|
||||
|
||||
The page is a standard Home Assistant MENU step: Home Assistant renders the
|
||||
rows, stores the choice as ``next_step_id``, and calls
|
||||
``async_step_<choice>(None)``. Every sub-screen merges into ONE pending
|
||||
options dict and returns to the menu, so an edit made on one sub-screen can
|
||||
never be lost by visiting another — the merge is key-scoped and
|
||||
deterministic, never last-writer-wins. "Save and close" commits it (and
|
||||
reloads through the OptionsFlowWithReload base).
|
||||
|
||||
Suggested values come through ``add_suggested_values_to_schema`` and are
|
||||
handed the SERIALIZED field values from ``_options_field_values``: the apps
|
||||
map is a dict in storage but must be the documented ``package=name[|icon]``
|
||||
text on the wire, or the browser renders ``[object Object]`` (t56).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -865,154 +1165,390 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
self._options = {}
|
||||
return self._options
|
||||
|
||||
# ── the menu (t56) ──
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Single-page options."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# t47 F1: a field submitted as None means "not supplied" — only an
|
||||
# explicit empty value ('' or a cleared selector) clears a setting, so
|
||||
# the two cannot be confused. Note the schema itself already tolerates
|
||||
# the empty value (see _ClearingEntitySelector).
|
||||
user_input = {key: value for key, value in user_input.items() if value is not None}
|
||||
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
||||
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
|
||||
if delay is not None:
|
||||
try:
|
||||
delay = float(delay)
|
||||
except (TypeError, ValueError):
|
||||
errors["base"] = "invalid_digit_delay"
|
||||
delay = None
|
||||
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||
errors["base"] = "invalid_digit_delay"
|
||||
# t44: one selector per command, stored as a single dict option so
|
||||
# the service layer reads one shape. Popped here because _options_dict
|
||||
# is updated with the rest of user_input verbatim below.
|
||||
scripts = {
|
||||
command: script
|
||||
for command in SCRIPT_COMMANDS
|
||||
if (
|
||||
script := str(
|
||||
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
|
||||
).strip()
|
||||
"""The options menu: Main setup / Apps / Scripts / Receiver / Save.
|
||||
|
||||
A MENU result never reaches this method with form data: the flow
|
||||
manager reads ``next_step_id`` off the menu result and calls the chosen
|
||||
step directly. Returning the menu here is what every sub-screen comes
|
||||
back to.
|
||||
|
||||
``discover`` is the explicit refresh (t57): it runs the ADB sweep and
|
||||
reports what it found, so the app table is never built by accident and
|
||||
nothing sweeps on a state poll.
|
||||
"""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=[
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
],
|
||||
description_placeholders=self._menu_placeholders(),
|
||||
)
|
||||
}
|
||||
# t47: one optional script for the remote entity — a plain string
|
||||
# option, unlike the per-command dict above
|
||||
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||
if remote_script is not None:
|
||||
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
|
||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||
icons: dict[str, str] = {}
|
||||
if app_map_text is None:
|
||||
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
|
||||
elif app_map_text.strip() == "":
|
||||
app_map = {}
|
||||
else:
|
||||
app_map = _app_map_from_text(app_map_text, icons)
|
||||
if app_map is None:
|
||||
errors["base"] = "invalid_app_map"
|
||||
if not errors:
|
||||
if delay is not None:
|
||||
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||
self._options_dict.update(user_input)
|
||||
self._options_dict[CONF_APP_NAME_MAP] = app_map
|
||||
# P5-2: the icon column — the editor is the source of
|
||||
# truth, so a save without icons clears them (and a
|
||||
# single-column map stays valid).
|
||||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||
# an empty dict clears every override (the editor is the truth)
|
||||
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
|
||||
|
||||
def _menu_placeholders(self) -> dict:
|
||||
"""What the menu says about the current discovery state (t57)."""
|
||||
apps = self._discovered_apps()
|
||||
if apps:
|
||||
return {"discovery": f"{len(apps)} apps found on the Shield"}
|
||||
manager = getattr(self.config_entry, "runtime_data", None) if (
|
||||
self.config_entry is not None
|
||||
) else None
|
||||
if manager is not None and not getattr(manager, "connected", False):
|
||||
return {"discovery": "no ADB connection — nothing discovered yet"}
|
||||
return {"discovery": "not discovered yet — use Discover installed apps"}
|
||||
|
||||
async def async_step_discover(self, user_input=None):
|
||||
"""Discover the apps installed on the Shield over ADB (t57).
|
||||
|
||||
A menu ROW, not a form: Home Assistant dispatches ``next_step_id`` to
|
||||
this method with no user input, it awaits the manager's bounded sweep,
|
||||
and the menu comes back with a line saying what was found. Running it
|
||||
from a row keeps the sweep explicit (never a per-poll cost) and gives it
|
||||
a place to report a failure without a new field.
|
||||
"""
|
||||
await self._run_discovery()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def _run_discovery(self) -> None:
|
||||
"""Sweep the device, swallowing every failure into the menu's note.
|
||||
|
||||
Discovery is a convenience: when ADB is off or down, or the sweep finds
|
||||
nothing, the menu must still render and the table must still work on the
|
||||
user's own rows. So nothing here raises — a failure simply leaves the
|
||||
discovered list empty, which is what the placeholders report.
|
||||
"""
|
||||
entry = self.config_entry
|
||||
manager = getattr(entry, "runtime_data", None) if entry is not None else None
|
||||
if manager is None:
|
||||
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
|
||||
manager = store.get(getattr(entry, "entry_id", None))
|
||||
sweep = getattr(manager, "async_refresh_installed_apps", None)
|
||||
if sweep is None:
|
||||
return
|
||||
try:
|
||||
await sweep(force=True)
|
||||
except Exception as err: # noqa: BLE001 - a failed sweep is not an error
|
||||
_LOGGER.debug("app discovery from the options page failed: %s", err)
|
||||
|
||||
async def async_step_menu_again(self):
|
||||
"""Return to the options menu after a sub-screen saved (t56).
|
||||
|
||||
Deliberately NOT ``async_step_init``: HA dispatches a menu row to
|
||||
``async_step_<row id>`` by NAME, so ``async_step_init`` is not what the
|
||||
menu returns to — and calling it here would also bypass the ``step_id``
|
||||
the browser expects on the next render.
|
||||
"""
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_finish(self, user_input=None):
|
||||
"""Save and close: commit the merged options (the base reloads)."""
|
||||
return self.async_create_entry(title="", data=self._options_dict)
|
||||
|
||||
# ── the ONE merge rule ──
|
||||
|
||||
def _apply_submission(self, user_input: dict, errors: dict) -> bool:
|
||||
"""Merge a sub-screen submission into the pending options.
|
||||
|
||||
The rule, so "cleared" and "untouched" can never be confused:
|
||||
|
||||
* a key the submission CARRIES is authoritative — an empty value (``''``
|
||||
from a cleared selector, ``None`` from an explicit null, a whitespace
|
||||
string) CLEARS the setting, a value sets it;
|
||||
* a key the submission does NOT carry is untouched and keeps its stored
|
||||
value.
|
||||
|
||||
Real Home Assistant submits every field the step rendered, so a missing
|
||||
key only ever means "that field was not on this screen" (verified with a
|
||||
real FlowManager: the submitted ``user_input`` is exactly the coerced
|
||||
schema output for the rendered fields). The per-command scripts are the
|
||||
one nested-shaped option, so when ANY script field is part of a
|
||||
submission the whole dict is rebuilt from the submitted fields: that is
|
||||
what makes unsetting a script persist instead of surviving as a stale
|
||||
key, and it keeps ``command_scripts`` in the shape the service layer
|
||||
reads (flat ``command -> script``).
|
||||
|
||||
Returns True when the submission is valid and merged.
|
||||
"""
|
||||
present = {
|
||||
key: value
|
||||
for key, value in user_input.items()
|
||||
if key in _ALL_OPTION_FIELDS
|
||||
}
|
||||
|
||||
# ── the apps table: parse or reject, never half-store ──
|
||||
if CONF_APP_NAME_MAP in present:
|
||||
parsed = _app_map_submission(present[CONF_APP_NAME_MAP])
|
||||
if parsed is None:
|
||||
errors["base"] = "invalid_app_map"
|
||||
return False
|
||||
app_map, icon_map = parsed
|
||||
present[CONF_APP_NAME_MAP] = app_map
|
||||
present[CONF_APP_ICON_MAP] = icon_map
|
||||
|
||||
# ── the channel digit delay (§7 window) ──
|
||||
if CONF_CHANNEL_DIGIT_DELAY in present:
|
||||
try:
|
||||
delay = float(present[CONF_CHANNEL_DIGIT_DELAY])
|
||||
except (TypeError, ValueError):
|
||||
errors["base"] = "invalid_digit_delay"
|
||||
return False
|
||||
if not 0.05 <= delay <= 1.0:
|
||||
errors["base"] = "invalid_digit_delay"
|
||||
return False
|
||||
present[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||
|
||||
# ── the per-command scripts: per FIELD, which is the unambiguous rule ──
|
||||
#
|
||||
# A cleared selector arrives as an empty value for THAT field (t57
|
||||
# UNIT 0a verified the shape against real HA), so clearing is a field
|
||||
# operation: a `script_<command>` key that is present and empty REMOVES
|
||||
# its command, one that is present with a value sets it, and a key the
|
||||
# submission does not carry is untouched — the same rule as every other
|
||||
# option. Nothing depends on the page being complete, so a hand-made
|
||||
# partial submission cannot silently wipe the commands it omits. The
|
||||
# starting point is the normalized `command_scripts` view, so a command
|
||||
# this version no longer knows about is dropped rather than carried.
|
||||
script_fields = [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]
|
||||
if script_fields:
|
||||
scripts = dict(command_scripts(self._options_dict))
|
||||
for field in script_fields:
|
||||
command = field[len(SCRIPT_FIELD_PREFIX):]
|
||||
value = str(present[field] or "").strip()
|
||||
if command in SCRIPT_COMMANDS and value:
|
||||
scripts[command] = value
|
||||
else:
|
||||
# cleared, or a field this version has no command for
|
||||
scripts.pop(command, None)
|
||||
# UI only: the service layer reads the dict, never a flat key
|
||||
present.pop(field)
|
||||
present[CONF_COMMAND_SCRIPTS] = scripts
|
||||
|
||||
for key, value in present.items():
|
||||
if value is None:
|
||||
value = ""
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
self._options_dict[key] = value
|
||||
return True
|
||||
|
||||
# ── sub-screens ──
|
||||
|
||||
async def async_step_connection(self, user_input=None):
|
||||
"""Main setup: the link, both transports and the linked entities."""
|
||||
errors: dict = {}
|
||||
if user_input is not None and self._apply_submission(dict(user_input), errors):
|
||||
return await self.async_step_menu_again()
|
||||
return self._show_connection_form(errors)
|
||||
|
||||
def _show_connection_form(self, errors=None):
|
||||
"""Main setup: the same settings the first setup form carries.
|
||||
|
||||
Deliberately its own field list rather than ``_user_schema_dict``: that
|
||||
builder renders the entry's NAME (a config-entry title, not an option —
|
||||
it would be a field whose edits are silently dropped) and it takes no
|
||||
logcat/adb-command toggles. The field NAMES stay identical, which is
|
||||
what a sub-screen has to preserve.
|
||||
"""
|
||||
sel = _build_selectors()
|
||||
schema_dict = {}
|
||||
options = self._options_dict
|
||||
port = options.get(CONF_ADB_PORT)
|
||||
schema_dict = {
|
||||
_host_field_key(CONF_HOST, str(options.get(CONF_HOST) or "")): str,
|
||||
vol.Optional(
|
||||
CONF_ADB_PORT, default=port if isinstance(port, int) else DEFAULT_ADB_PORT
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_ADB_ENABLED, default=bool(options.get(CONF_ADB_ENABLED, True))
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
default=bool(options.get(CONF_ADB_STORE_APP_INFO, True)),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
default=bool(options.get(CONF_PROTOCOL_ENABLED, True)),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_LOGCAT_EVENTS, default=bool(options.get(CONF_LOGCAT_EVENTS, False))
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)),
|
||||
): bool,
|
||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||
vol.Optional(CONF_POWER_ENTITY): sel["power"],
|
||||
}
|
||||
return self._show_sub_form("connection", schema_dict, errors)
|
||||
|
||||
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||
# the optional icon column).
|
||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||
async def async_step_receiver(self, user_input=None):
|
||||
"""Receiver: the media_player OR switch this Shield plays through."""
|
||||
errors: dict = {}
|
||||
if user_input is not None and self._apply_submission(dict(user_input), errors):
|
||||
return await self.async_step_menu_again()
|
||||
return self._show_receiver_form(errors)
|
||||
|
||||
# Jellyfin
|
||||
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||
if jf:
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||
schema_dict[jf_key] = sel["media_player"]
|
||||
|
||||
# Receiver
|
||||
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
if recv:
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
schema_dict[recv_key] = sel["receiver"]
|
||||
|
||||
# t44: command script overrides — the escape hatch for hardware we do not
|
||||
# drive (a speaker switch owning the volume). Unset = native path as before.
|
||||
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
|
||||
|
||||
configured_scripts = command_scripts(self._options_dict)
|
||||
suggested_scripts = {}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
field = f"{SCRIPT_FIELD_PREFIX}{command}"
|
||||
schema_dict[vol.Optional(field)] = sel["script"]
|
||||
if configured_scripts.get(command):
|
||||
suggested_scripts[field] = configured_scripts[command]
|
||||
|
||||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||
|
||||
# P5-3: the live events push is opt-in (default off; the user
|
||||
# tests stability on their device and reports)
|
||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||||
|
||||
# P4-2a: the raw adb_command service is opt-in (default off)
|
||||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||||
|
||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||||
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||
|
||||
if inputs:
|
||||
input_sel = selector.SelectSelector(
|
||||
def _show_receiver_form(self, errors=None):
|
||||
sel = _build_selectors()
|
||||
# Receiver input: a dropdown from the DAG input map when the linked
|
||||
# entity publishes one, a plain text field otherwise — a plain switch
|
||||
# never publishes one, so it gets the text field.
|
||||
inputs = _get_receiver_inputs(
|
||||
self.hass, self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
)
|
||||
input_field = (
|
||||
selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=inputs,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||
if current_input:
|
||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||
schema_dict[input_key] = input_sel
|
||||
else:
|
||||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||
|
||||
# Power
|
||||
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
if pwr:
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
schema_dict[pwr_key] = sel["power"]
|
||||
|
||||
# Transport toggles (t41: both transports are ours; the skip paths and
|
||||
# this page are the way to turn either one off)
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||
|
||||
# The ONE address field (t41), plus the ADB port
|
||||
schema_dict[vol.Optional(CONF_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||
|
||||
# t29 G3b: suggested values come from the entry options via the
|
||||
# documented helper — no per-key `default=` in the schema build.
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict),
|
||||
{**self._options_dict, **suggested_scripts},
|
||||
),
|
||||
errors=errors,
|
||||
if inputs
|
||||
else str
|
||||
)
|
||||
schema_dict = {
|
||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||
vol.Optional(CONF_RECEIVER_INPUT): input_field,
|
||||
}
|
||||
return self._show_sub_form("receiver", schema_dict, errors)
|
||||
|
||||
async def async_step_apps(self, user_input=None):
|
||||
"""Apps: the editable apps table — one row per discovered + stored app."""
|
||||
errors: dict = {}
|
||||
if user_input is not None and self._apply_submission(dict(user_input), errors):
|
||||
return await self.async_step_menu_again()
|
||||
return self._show_apps_form(errors)
|
||||
|
||||
def _discovered_apps(self) -> list[dict]:
|
||||
"""The apps the device reported, from the last background sweep (t57).
|
||||
|
||||
Sources, in order: the entry's ``runtime_data`` (where the integration
|
||||
puts its connection manager) and then the domain store, both of which
|
||||
the options flow can reach through ``hass``. Never triggers a sweep:
|
||||
this is a pure read of the manager's TTL cache, so rendering a form
|
||||
cannot issue shells. When nothing has been discovered (ADB off or down,
|
||||
or the first sweep still running) the list is empty and the table shows
|
||||
the user's own rows — the empty state, not a crash.
|
||||
"""
|
||||
entry = self.config_entry
|
||||
manager = getattr(entry, "runtime_data", None) if entry is not None else None
|
||||
if manager is None:
|
||||
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
|
||||
manager = store.get(getattr(entry, "entry_id", None))
|
||||
if manager is None or not hasattr(manager, "installed_apps"):
|
||||
return []
|
||||
return manager.installed_apps()
|
||||
|
||||
def _show_apps_form(self, errors=None):
|
||||
stored = self._options_dict
|
||||
schema_dict = {
|
||||
# One ROW per line, exactly the stored shape and the documented
|
||||
# editor format: `package=name` with an optional `|icon`. The rows
|
||||
# are the user's own map PLUS everything discovery found on the
|
||||
# device, and the field carries TEXT in every case (never the
|
||||
# stored dict — that was the [object Object] bug).
|
||||
vol.Optional(CONF_APP_NAME_MAP): str,
|
||||
}
|
||||
discovered = self._discovered_apps()
|
||||
suggested = _apps_table_text(
|
||||
stored.get(CONF_APP_NAME_MAP) or {},
|
||||
stored.get(CONF_APP_ICON_MAP) or {},
|
||||
discovered,
|
||||
)
|
||||
icons = sorted({app.get("icon") for app in discovered if app.get("icon")})
|
||||
return self._show_sub_form(
|
||||
"apps",
|
||||
schema_dict,
|
||||
errors,
|
||||
override={CONF_APP_NAME_MAP: suggested},
|
||||
placeholders={"icons": ", ".join(icons) or "none known"},
|
||||
)
|
||||
|
||||
async def async_step_scripts(self, user_input=None):
|
||||
"""Scripts: per-command overrides and the channel digit behaviour."""
|
||||
errors: dict = {}
|
||||
if user_input is not None and self._apply_submission(dict(user_input), errors):
|
||||
return await self.async_step_menu_again()
|
||||
return self._show_scripts_form(errors)
|
||||
|
||||
def _show_scripts_form(self, errors=None):
|
||||
sel = _build_selectors()
|
||||
schema_dict = {vol.Optional(CONF_REMOTE_COMMAND_SCRIPT): sel["script"]}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
schema_dict[vol.Optional(f"{SCRIPT_FIELD_PREFIX}{command}")] = sel["script"]
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||
return self._show_sub_form("scripts", schema_dict, errors)
|
||||
|
||||
# ── rendering ──
|
||||
|
||||
def _show_sub_form(
|
||||
self,
|
||||
step_id: str,
|
||||
schema_dict: dict,
|
||||
errors: dict | None,
|
||||
override: dict | None = None,
|
||||
placeholders: dict | None = None,
|
||||
):
|
||||
"""Render a sub-screen with only ITS suggested values — never a dict.
|
||||
|
||||
``_options_field_values`` is what serializes the apps map for the wire,
|
||||
and the script selectors are suggested from the normalized
|
||||
``command_scripts`` dict — the same accessor the service layer reads, so
|
||||
the page cannot offer a script the runtime would ignore.
|
||||
|
||||
``override`` replaces individual suggested values for a screen whose
|
||||
suggestion is not simply the stored option (the Apps table is the
|
||||
stored map MERGED with discovery, t57). ``placeholders`` feeds the
|
||||
step's ``description_placeholders``, which is how a screen reports
|
||||
something long — the Discover row's outcome — without inventing a field
|
||||
for it.
|
||||
"""
|
||||
suggested = _options_field_values(self._options_dict)
|
||||
for command, script in command_scripts(self._options_dict).items():
|
||||
suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script
|
||||
suggested.update(override or {})
|
||||
kwargs: dict = {}
|
||||
if placeholders:
|
||||
kwargs["description_placeholders"] = dict(placeholders)
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict), suggested
|
||||
),
|
||||
errors=errors or {},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
#: Every field the options page can render. A submission key outside this set is
|
||||
#: menu/HA bookkeeping (`next_step_id`) and must never reach the stored options.
|
||||
_ALL_OPTION_FIELDS = frozenset(
|
||||
{
|
||||
CONF_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_LOGCAT_EVENTS,
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_APP_ICON_MAP,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
CONF_CHANNEL_SEND_ENTER,
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
|
||||
}
|
||||
)
|
||||
@@ -36,7 +36,13 @@ from .const import (
|
||||
ADB_RECONNECT_BACKOFF_CAP,
|
||||
ADB_RECONNECT_BACKOFF_START,
|
||||
ADB_RECONNECT_JITTER,
|
||||
APP_ICON_FALLBACKS,
|
||||
APP_ICON_GENERIC,
|
||||
APP_LABEL_EXIT_MARKER,
|
||||
APP_LABEL_TTL,
|
||||
APP_LIST_TTL,
|
||||
APP_SWEEP_CONCURRENCY,
|
||||
APP_SWEEP_SPACING,
|
||||
CONF_LOGCAT_EVENTS,
|
||||
LOGCAT_SUPPRESS_WINDOW,
|
||||
STATE_STORE_TTL,
|
||||
@@ -45,6 +51,9 @@ from .const import (
|
||||
from .ha_adb_common import (
|
||||
MediaSessionInfo,
|
||||
foreground_from_logcat,
|
||||
icon_for_package,
|
||||
installed_packages,
|
||||
is_launchable,
|
||||
parse_application_label,
|
||||
parse_audio_playback,
|
||||
)
|
||||
@@ -206,7 +215,34 @@ class ShieldConnectionManager:
|
||||
# P5-1: device manifest labels — package → (label, stamp). Long-TTL
|
||||
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
|
||||
# package per watchdog cycle so a burst never blocks the watchdog.
|
||||
# An entry means "a fetch RAN" (t57 UNIT 0): a tuple whose label is
|
||||
# "" is a definitive negative, and a fetch that could not run is not
|
||||
# in here at all — so the flag t55 carried to say so was dead weight
|
||||
# (its only writer always passed True) and is gone.
|
||||
self._app_labels: dict[str, tuple[str, float]] = {}
|
||||
self._app_label_requests: set[str] = set()
|
||||
# t55: packages whose label fetch is queued or in flight — a render
|
||||
# during the fetch must not enqueue the same package twice.
|
||||
self._app_label_inflight: set[str] = set()
|
||||
# t57: the discovered installed-apps table — (rows, stamp), or None when
|
||||
# no sweep has produced a fresh answer. Only a sweep that RAN writes
|
||||
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
|
||||
# instead, because stamping an empty answer would serve "0 apps" — and
|
||||
# block every retry — for the whole APP_LIST_TTL. Refreshed on demand;
|
||||
# the watchdog cycle never rebuilds it.
|
||||
self._installed_apps: tuple[list[dict], float] | None = None
|
||||
# t62 F1: True when the last sweep could not deliver an answer (no
|
||||
# poller, ADB down, a shell that raised or answered nothing) — so an
|
||||
# empty installed_apps() is "unknown", not "this device has no apps".
|
||||
self._apps_unavailable = False
|
||||
# ...and the clock stamp of an ATTEMPTED failure (shells really went
|
||||
# out). A retry then costs two listing shells, so it is spaced by
|
||||
# APP_SWEEP_RETRY_SECONDS instead of firing on every state poll; an
|
||||
# unavailable transport costs nothing and retries immediately.
|
||||
self._apps_failed_at: float | None = None
|
||||
#: t57: the in-flight sweep, so concurrent callers share one sweep
|
||||
#: instead of stacking a second burst on the same connection.
|
||||
self._apps_sweep: asyncio.Task | None = None
|
||||
# P5-3 (C-STREAM): the dedicated logcat line-stream consumer.
|
||||
# NEVER shares the poller's ADB socket — its own device object and
|
||||
# connection; CONF_LOGCAT_EVENTS gates it (default off) and the
|
||||
@@ -221,7 +257,6 @@ class ShieldConnectionManager:
|
||||
self._logcat_last_stamp: float = -1e9
|
||||
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
|
||||
self._logcat_ladder_sleep = asyncio.sleep
|
||||
self._app_label_requests: set[str] = set()
|
||||
# Injectable clock keeps the TTL expiry tests deterministic.
|
||||
self._clock = clock
|
||||
self._tasks: list[asyncio.Task] = []
|
||||
@@ -548,12 +583,28 @@ class ShieldConnectionManager:
|
||||
return False
|
||||
|
||||
def app_label(self, package: str | None) -> str | None:
|
||||
"""Cached manifest label for a package (P5-1), or None.
|
||||
"""Cached manifest label for a package (P5-1), or None (t55).
|
||||
|
||||
A cache miss enqueues an on-demand `pm dump` fetch — drained one
|
||||
package per watchdog cycle via the manager task pool, so entity
|
||||
refreshes and the watchdog are never blocked by a burst. A failed
|
||||
resolution simply falls through to the next name tier.
|
||||
refreshes and the watchdog are never blocked by a burst.
|
||||
|
||||
What is cached is the FETCH, not the wishful result (t55 root cause):
|
||||
|
||||
* a fetch that RAN caches its outcome for ``APP_LABEL_TTL`` — a label
|
||||
when the manifest has one, and the empty string otherwise. It is
|
||||
definitive: the device answered, the package simply has no default
|
||||
label (several system/OEM packages do not). No re-enqueue, so a
|
||||
permanently label-less package costs ZERO further round trips
|
||||
(t24 F1 stays closed).
|
||||
* a fetch that could NOT run (no poller, ADB down, shell error) caches
|
||||
nothing, so the next render enqueues again — a miss is retried
|
||||
visibly instead of being stamped as an eternal "no label". This was
|
||||
the live defect: the old code stored ("", stamp) on every failure
|
||||
path, which silenced the label tier for the whole 24 h TTL and left
|
||||
app_name showing the raw package id.
|
||||
* while a fetch for the package is queued or in flight, the next
|
||||
render does not enqueue it a second time.
|
||||
"""
|
||||
if not package:
|
||||
return None
|
||||
@@ -561,13 +612,58 @@ class ShieldConnectionManager:
|
||||
if cached is not None:
|
||||
label, stamp = cached
|
||||
if self._clock() - stamp <= APP_LABEL_TTL:
|
||||
# empty label = negative cache (t24 F1): the pm dump ran and
|
||||
# found nothing — do not re-enqueue, zero per-heartbeat tax
|
||||
# empty label = negative cache (t24 F1): the pm dump RAN and
|
||||
# found nothing — do not re-enqueue, zero per-heartbeat tax.
|
||||
# A fetch that did not run is not cached at all (t57 UNIT 0),
|
||||
# so there is no "unresolved" shape to fall through on.
|
||||
return label or None
|
||||
self._app_labels.pop(package, None) # stale — refetch on demand
|
||||
if package not in self._app_label_requests and (
|
||||
package not in self._app_label_inflight
|
||||
):
|
||||
self._app_label_requests.add(package)
|
||||
return None
|
||||
|
||||
def _known_label(self, package: str) -> tuple[bool, str]:
|
||||
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
|
||||
|
||||
The accessor the DISCOVERY sweep needs and ``app_label`` deliberately is
|
||||
not: ``app_label`` answers "what should be displayed", so a definitive
|
||||
negative (a package with no manifest label) comes back as ``None``,
|
||||
exactly like a miss. A sweep that used it would re-dump every label-less
|
||||
package on every run and throw away the 24 h cache it is supposed to
|
||||
benefit from. Here the two are distinguishable.
|
||||
"""
|
||||
cached = self._app_labels.get(package)
|
||||
if cached is None:
|
||||
return False, ""
|
||||
label, stamp = cached
|
||||
if self._clock() - stamp > APP_LABEL_TTL:
|
||||
self._app_labels.pop(package, None) # stale — refetch on demand
|
||||
return False, ""
|
||||
return True, label
|
||||
|
||||
def cache_app_label(self, package: str, label: str | None) -> None:
|
||||
"""Record a label fetch that RAN, and what it found (t55, t57).
|
||||
|
||||
The cache's only writer. ``None``/"" is the negative result devices
|
||||
genuinely report for label-less packages, and it is definitive: no
|
||||
further ``pm dump`` for ``APP_LABEL_TTL``. A fetch that did NOT run
|
||||
must not call this — that was the live defect (see
|
||||
``_fetch_app_label``).
|
||||
"""
|
||||
self._app_labels[package] = (label or "", self._clock())
|
||||
|
||||
@property
|
||||
def app_label_requests(self) -> set[str]:
|
||||
"""Packages queued for a manifest-label fetch (public, for tests)."""
|
||||
return set(self._app_label_requests)
|
||||
|
||||
@property
|
||||
def app_label_inflight(self) -> set[str]:
|
||||
"""Packages whose label fetch is running right now (public, tests)."""
|
||||
return set(self._app_label_inflight)
|
||||
|
||||
async def async_capture_protocol_device_data(self) -> None:
|
||||
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
|
||||
if self._protocol is None or self.protocol_device_data is not None:
|
||||
@@ -586,24 +682,79 @@ class ShieldConnectionManager:
|
||||
except Exception as err:
|
||||
_LOGGER.debug("protocol device info unavailable: %s", err)
|
||||
|
||||
async def _fetch_app_label(self, package: str) -> None:
|
||||
"""One `pm dump` label fetch; failure falls through silently."""
|
||||
async def _dump_label(self, package: str) -> str | None:
|
||||
"""One `pm dump` label read: the label, or None for a FAILED fetch.
|
||||
|
||||
The ONE place the dump is issued, shared by the on-demand path
|
||||
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
|
||||
distinction — "the command ran and found no label" versus "the
|
||||
transport delivered nothing" — cannot drift between them. A successful
|
||||
fetch is also cached here, so both paths benefit from the 24 h cache and
|
||||
neither double-stamps.
|
||||
|
||||
Returns the label (possibly "") when the dump RAN, and None when it did
|
||||
not run or delivered no output at all.
|
||||
|
||||
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
|
||||
after a ``head`` pipeline, and the Android shell that runs this has no
|
||||
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
|
||||
reads its VALUE — ``parse_application_label`` strips the marker line — and
|
||||
the only property tested here is that a command which RAN leaves non-blank
|
||||
output even when the device prints no label. That non-blankness is what
|
||||
separates "ran, no label" from "the transport delivered nothing", which
|
||||
this method turns into a cached negative versus a retry.
|
||||
"""
|
||||
poller = self._poller
|
||||
if poller is None or not self.connected:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"app label for %s not fetched (ADB not connected) — will "
|
||||
"retry on the next render",
|
||||
package,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
import shlex
|
||||
|
||||
dump = await poller.shell(
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
|
||||
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||
)
|
||||
label = parse_application_label(dump or "")
|
||||
except Exception as err:
|
||||
_LOGGER.debug("app label fetch failed for %s: %s", package, err)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"app label fetch failed for %s (%s) — will retry on the "
|
||||
"next render",
|
||||
package,
|
||||
err,
|
||||
)
|
||||
return None
|
||||
if dump is None or not dump.strip():
|
||||
# the transport never delivered output: a FAILED fetch, not an
|
||||
# answer. Caching here is the t55 defect this repairs.
|
||||
_LOGGER.debug(
|
||||
"app label fetch for %s returned no output — will retry on "
|
||||
"the next render",
|
||||
package,
|
||||
)
|
||||
return None
|
||||
label = parse_application_label(dump)
|
||||
# F1: label-less pm dumps negatively cache too (empty label) so a
|
||||
# later cycle performs zero additional pm dumps for the package
|
||||
self._app_labels[package] = (label or "", self._clock())
|
||||
# later cycle performs zero additional pm dumps for the package.
|
||||
self.cache_app_label(package, label)
|
||||
return label
|
||||
|
||||
async def _fetch_app_label(self, package: str) -> None:
|
||||
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
|
||||
|
||||
The on-demand half of the label path (one package per watchdog cycle,
|
||||
driven by an entity render). The dump itself — including the t57 UNIT 0
|
||||
distinction between a FAILED fetch and a genuinely label-less package —
|
||||
lives in :meth:`_dump_label`, so the sweep and this path cannot drift.
|
||||
"""
|
||||
self._app_label_inflight.add(package)
|
||||
try:
|
||||
await self._dump_label(package)
|
||||
finally:
|
||||
self._app_label_inflight.discard(package)
|
||||
|
||||
def _on_logcat_app_start(self, package: str) -> None:
|
||||
"""Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3).
|
||||
@@ -756,6 +907,228 @@ class ShieldConnectionManager:
|
||||
self._logcat_task.cancel()
|
||||
self._logcat_task = None
|
||||
|
||||
# ── t57: installed-app discovery ───────────────────────────────────────
|
||||
#
|
||||
# MARK — documented, not observed (t62 nit c): the output SHAPES the two
|
||||
# listing commands rely on (`pm list packages -3` → `package:<id>` lines,
|
||||
# `cmd package resolve-activity --brief -c …HOME` → `package/activity`) are
|
||||
# Android's documentation, not a captured run: no real Shield was available
|
||||
# when discovery was written (t57). Every fixture in
|
||||
# tests/test_installed_apps.py is built from those shapes and the parsers in
|
||||
# ``ha_adb_common.installed_apps`` are deliberately tolerant of whitespace
|
||||
# and ordering. Same mark as the state=6 buffering inference in
|
||||
# ``media_player.py``.
|
||||
|
||||
#: t62 F1: how long after an ATTEMPTED sweep failed before another one is
|
||||
#: issued. A retry costs two listing shells, so it waits out one state poll
|
||||
#: (30 s) rather than hammering a silent socket; ``force=True`` bypasses it.
|
||||
#: An unavailable transport (no poller, ADB down) costs nothing and is
|
||||
#: retried on the very next update.
|
||||
APP_SWEEP_RETRY_SECONDS: float = 30.0
|
||||
|
||||
def installed_apps(self) -> list[dict]:
|
||||
"""The discovered apps, ``[{package, name, icon}]``, cached (t57).
|
||||
|
||||
Returns the cached table whenever it is fresh, and an EMPTY list when
|
||||
there is no fresh answer: ADB disabled, not connected, the first sweep
|
||||
still running, or a sweep that could not run (t62 F1). Empty is
|
||||
therefore "nothing to show", never by itself "this device has no apps"
|
||||
— ask :meth:`installed_apps_status` which of the two it is. A caller
|
||||
that wants the table filled calls
|
||||
:meth:`async_refresh_installed_apps`; this accessor never blocks and
|
||||
never issues a shell itself, so a state poll stays free of discovery
|
||||
work.
|
||||
"""
|
||||
if self._installed_apps is None:
|
||||
return []
|
||||
rows, stamp = self._installed_apps
|
||||
if self._clock() - stamp > APP_LIST_TTL:
|
||||
return [] # stale — a refresh repopulates it, the poll does not
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@property
|
||||
def installed_apps_stale(self) -> bool:
|
||||
"""True when no fresh table is available (including "never swept")."""
|
||||
if self._installed_apps is None:
|
||||
return True
|
||||
_rows, stamp = self._installed_apps
|
||||
return self._clock() - stamp > APP_LIST_TTL
|
||||
|
||||
@property
|
||||
def installed_apps_status(self) -> str:
|
||||
"""Why :meth:`installed_apps` may be empty, in one word (t62 F1).
|
||||
|
||||
* ``"ok"`` — a sweep RAN and its answer is fresh. An empty table with
|
||||
this status is a real answer (the device reported no launchable
|
||||
apps), which is the case the old stamped-empty cache could not say.
|
||||
* ``"unknown"`` — the last sweep could not deliver an answer (no
|
||||
poller, ADB down, a shell that raised or answered nothing). Nothing
|
||||
is known about the device, and the next update retries.
|
||||
* ``"pending"`` — no sweep has completed yet, or the last answer
|
||||
expired and a refresh is due.
|
||||
"""
|
||||
if not self.installed_apps_stale:
|
||||
return "ok"
|
||||
return "unknown" if self._apps_unavailable else "pending"
|
||||
|
||||
async def async_refresh_installed_apps(self, force: bool = False) -> list[dict]:
|
||||
"""(Re)build the installed-app table over ADB, bounded and shared (t57).
|
||||
|
||||
One sweep at a time: concurrent callers await the same task rather than
|
||||
starting a second burst, and a fresh table is returned as-is unless
|
||||
``force``. The sweep is deliberately NOT part of the watchdog: it is a
|
||||
handful of shells on demand, not a per-cycle cost.
|
||||
|
||||
Failure is not an answer (t62 F1). No poller, a disconnected transport,
|
||||
a shell that raises, or a sweep that delivered no output records
|
||||
``unknown`` and stores NO table, so the next non-forced call — an
|
||||
entity poll or the standard ``update_entity`` action — retries instead
|
||||
of serving a stamped empty answer for the whole APP_LIST_TTL. A sweep
|
||||
that reached the device and failed is retried at most once per
|
||||
``APP_SWEEP_RETRY_SECONDS`` so a silent socket is not hammered; a sweep
|
||||
that RAN stores its answer, including a genuinely empty list.
|
||||
"""
|
||||
if not force and not self.installed_apps_stale:
|
||||
return self.installed_apps()
|
||||
if (
|
||||
not force
|
||||
and self._apps_failed_at is not None
|
||||
and self._clock() - self._apps_failed_at < self.APP_SWEEP_RETRY_SECONDS
|
||||
):
|
||||
# The previous attempt really issued shells and failed: space the
|
||||
# retries by one poll interval instead of re-issuing them every
|
||||
# 30 s on a socket that is not answering (t62 F1).
|
||||
return self.installed_apps()
|
||||
if self._apps_sweep is not None and not self._apps_sweep.done():
|
||||
await asyncio.shield(self._apps_sweep)
|
||||
return self.installed_apps()
|
||||
task = self._create_task(self._sweep_installed_apps(), "apps_sweep")
|
||||
self._apps_sweep = task
|
||||
try:
|
||||
rows = await task
|
||||
finally:
|
||||
if self._apps_sweep is task:
|
||||
self._apps_sweep = None
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def _sweep_installed_apps(self) -> list[dict]:
|
||||
"""The sweep itself: list, label, icon — then stamp the cache (t57).
|
||||
|
||||
Only a sweep that RAN stamps the cache (t62 F1): every failure path
|
||||
records ``unknown`` and leaves ``_installed_apps`` alone, so the table
|
||||
the entity serves is the last one that was real — never a fabricated
|
||||
empty answer that also blocks the retry for APP_LIST_TTL.
|
||||
"""
|
||||
poller = self._poller
|
||||
if poller is None or not self.connected:
|
||||
_LOGGER.debug(
|
||||
"installed-app discovery skipped (ADB unavailable) — the "
|
||||
"options page and the app list stay on what is already known"
|
||||
)
|
||||
self._note_apps_unavailable(attempted=False)
|
||||
return []
|
||||
try:
|
||||
user_output = await poller.shell("pm list packages -3")
|
||||
launcher_output = await poller.shell(
|
||||
"cmd package resolve-activity --brief -c "
|
||||
"android.intent.category.HOME"
|
||||
)
|
||||
except Exception as err: # pragma: no cover - shell swallows its own
|
||||
_LOGGER.debug("installed-app discovery failed: %s", err)
|
||||
self._note_apps_unavailable(attempted=True)
|
||||
return []
|
||||
if user_output is None and launcher_output is None:
|
||||
# the transport delivered nothing at all: no list is NOT an answer
|
||||
_LOGGER.debug(
|
||||
"installed-app discovery got no output — will retry on the "
|
||||
"next update"
|
||||
)
|
||||
self._note_apps_unavailable(attempted=True)
|
||||
return []
|
||||
|
||||
packages = [p for p in installed_packages(user_output, launcher_output)
|
||||
if is_launchable(p)]
|
||||
labels = await self._sweep_labels(packages)
|
||||
rows = [
|
||||
{
|
||||
"package": package,
|
||||
"name": labels.get(package) or package,
|
||||
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
|
||||
}
|
||||
for package in packages
|
||||
]
|
||||
self._installed_apps = (rows, self._clock())
|
||||
self._apps_unavailable = False
|
||||
self._apps_failed_at = None
|
||||
_LOGGER.debug("installed-app discovery found %d apps", len(rows))
|
||||
return rows
|
||||
|
||||
def _note_apps_unavailable(self, *, attempted: bool) -> None:
|
||||
"""Record a sweep that could not deliver an answer (t62 F1).
|
||||
|
||||
``attempted`` says whether shells were actually issued: only then does
|
||||
the retry window apply, because only then does a retry cost anything.
|
||||
"""
|
||||
self._apps_unavailable = True
|
||||
self._apps_failed_at = self._clock() if attempted else None
|
||||
|
||||
async def _sweep_labels(self, packages: list[str]) -> dict[str, str]:
|
||||
"""Labels for a whole sweep, in a bounded burst (t57).
|
||||
|
||||
Deliberately NOT through ``_app_label_requests``: that queue is drained
|
||||
ONE package per watchdog cycle, which is right for the live entity path
|
||||
(a render must never fan out into shells) but would take a couple of
|
||||
minutes for a Shield with ~40 apps. Here the fetches run
|
||||
``APP_SWEEP_CONCURRENCY`` at a time with a short spacing, so a typical
|
||||
Shield finishes in seconds while the socket still sees a trickle rather
|
||||
than a flood.
|
||||
|
||||
Cached labels are reused, so a second sweep costs almost nothing; a
|
||||
failed fetch is NOT cached (t57 UNIT 0) and the app falls back to its
|
||||
package id for this table while the next sweep retries it.
|
||||
|
||||
The check goes through :meth:`_known_label` rather than
|
||||
:meth:`app_label`, so a package the device has definitively answered
|
||||
"no label" for is not re-dumped on every sweep.
|
||||
"""
|
||||
cached: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for package in packages:
|
||||
known, label = self._known_label(package)
|
||||
if not known:
|
||||
missing.append(package)
|
||||
elif label:
|
||||
cached[package] = label
|
||||
# a known NEGATIVE needs no entry: the row falls back to its
|
||||
# package id, and the cache already holds the definitive answer
|
||||
if not missing:
|
||||
return cached
|
||||
|
||||
semaphore = asyncio.Semaphore(APP_SWEEP_CONCURRENCY)
|
||||
|
||||
async def _one(package: str) -> tuple[str, str | None]:
|
||||
async with semaphore:
|
||||
# a small spacing keeps the burst polite on a single socket
|
||||
await asyncio.sleep(APP_SWEEP_SPACING)
|
||||
return package, await self._dump_label(package)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_one(package) for package in missing), return_exceptions=True
|
||||
)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
continue # one bad package never fails the sweep
|
||||
package, label = result
|
||||
if label:
|
||||
cached[package] = label
|
||||
else:
|
||||
# None here means the fetch FAILED (nothing cached); the empty
|
||||
# string means the device answered with no label. Both leave
|
||||
# the row on its package id, and both keep the app in the list.
|
||||
cached[package] = ""
|
||||
return cached
|
||||
|
||||
|
||||
def _drain_app_label_requests(self) -> None:
|
||||
"""Start at most one label fetch per watchdog cycle."""
|
||||
if not self._app_label_requests or self._poller is None:
|
||||
@@ -892,6 +1265,7 @@ class ShieldConnectionManager:
|
||||
self._last_known.clear()
|
||||
self._app_labels.clear()
|
||||
self._app_label_requests.clear()
|
||||
self._app_label_inflight.clear()
|
||||
if self._poller is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._poller.disconnect()
|
||||
|
||||
@@ -121,6 +121,13 @@ CONF_APP_ICON_MAP = "app_icon_map"
|
||||
# updates, not per poll).
|
||||
APP_LABEL_TTL = 24 * 3600
|
||||
|
||||
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
|
||||
# told from a package that genuinely has no label. ``adb shell`` output ending
|
||||
# in this marker means the command ran; an empty answer still carries it, and
|
||||
# a transport that never delivered output does not. The value is arbitrary but
|
||||
# unlikely to collide with a manifest line.
|
||||
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
|
||||
|
||||
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||
# (same security posture as the core androidtv integration).
|
||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||
@@ -144,6 +151,45 @@ DEFAULT_APP_DIRECTORY = {
|
||||
"com.google.android.youtube.tv": "YouTube",
|
||||
}
|
||||
|
||||
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
|
||||
# app's icon resource (an extracted APK's icon is a bitmap only its own code
|
||||
# can resolve, and `dumpsys package` prints no drawable reference), so a device
|
||||
# icon is not obtainable over plain shell. These MDI names are therefore a
|
||||
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
|
||||
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
|
||||
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
|
||||
APP_ICON_FALLBACKS = {
|
||||
"ar.tvplayer.tv": "mdi:television-classic",
|
||||
"com.github.damontecres.wholphin": "mdi:jellyfish",
|
||||
"com.netflix.ninja": "mdi:netflix",
|
||||
"com.google.android.youtube.tv": "mdi:youtube-tv",
|
||||
"com.google.android.tvlauncher": "mdi:view-dashboard",
|
||||
"org.xbmc.kodi": "mdi:kodi",
|
||||
"com.plexapp.android": "mdi:plex",
|
||||
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
|
||||
"com.disney.disneyplus": "mdi:movie-open",
|
||||
"com.spotify.tv.android": "mdi:spotify",
|
||||
"com.google.android.apps.youtube.music": "mdi:music",
|
||||
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
|
||||
"com.spotify.music": "mdi:spotify",
|
||||
}
|
||||
#: The fallback for an app this map has never heard of.
|
||||
APP_ICON_GENERIC = "mdi:television"
|
||||
|
||||
# t57: installed-app discovery. The package list changes only when the user
|
||||
# installs or uninstalls something, so a long TTL is right; refreshing is
|
||||
# explicit (the options page and the app-list sensor ask for it) rather than a
|
||||
# per-poll cost.
|
||||
APP_LIST_TTL = 6 * 3600
|
||||
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
|
||||
#: typical Shield quickly, few enough that the label fetches never queue
|
||||
#: behind each other on one socket or stall the watchdog.
|
||||
APP_SWEEP_CONCURRENCY = 4
|
||||
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
|
||||
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
|
||||
#: a few seconds instead of a few minutes.
|
||||
APP_SWEEP_SPACING = 0.05
|
||||
|
||||
CHANNEL_DIGIT_DELAY = 0.1
|
||||
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
||||
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
||||
|
||||
@@ -11,6 +11,13 @@ from .const import (
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||
)
|
||||
from .foreground import parse_foreground
|
||||
from .installed_apps import (
|
||||
icon_for_package,
|
||||
installed_packages,
|
||||
is_launchable,
|
||||
parse_active_launcher,
|
||||
parse_package_list,
|
||||
)
|
||||
from .intent import PlaybackIntent
|
||||
from .keys import ensure_adb_key
|
||||
from .logcat_events import foreground_from_logcat
|
||||
@@ -38,6 +45,11 @@ __all__ = [
|
||||
"parse_audio_playback",
|
||||
"parse_foreground",
|
||||
"foreground_from_logcat",
|
||||
"icon_for_package",
|
||||
"installed_packages",
|
||||
"is_launchable",
|
||||
"parse_active_launcher",
|
||||
"parse_package_list",
|
||||
"parse_wakefulness",
|
||||
"parse_media_session",
|
||||
"running_apps_from_sessions",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Discover the apps actually installed on the Shield (t57).
|
||||
|
||||
The live complaint ("it doesn't find all the apps") came from the curated
|
||||
fallback list: only four packages were ever offered, so a user with Wholphin,
|
||||
SmartTube, Kodi, Plex or anything else saw nothing. The device knows its own
|
||||
apps, so this module asks it.
|
||||
|
||||
The commands (both plain `pm`, available on Shield Experience 9 / Android 11
|
||||
with no extra permission beyond the ADB pairing this integration already has):
|
||||
|
||||
* ``pm list packages -3`` — third-party (user-installed) packages, one
|
||||
``package:<id>`` line each. This is what the live complaint is about: an app
|
||||
the user installed is a ``-3`` package.
|
||||
* ``cmd package resolve-activity --brief -c android.intent.category.HOME``
|
||||
— the ACTIVE launcher (``com.google.android.tvlauncher/....MainActivity``).
|
||||
A launcher is a system app, so it is not in ``-3``, yet it is exactly the app
|
||||
a user sees and may want to name or launch — so it is added explicitly.
|
||||
|
||||
System apps are otherwise NOT enumerated: the Shield ships a few hundred of
|
||||
them and they are not launchable apps a media-player source list wants. The
|
||||
one system app that matters (the launcher) is resolved, not filtered.
|
||||
|
||||
Labels come from the existing ``pm dump`` + ``parse_application_label`` path and
|
||||
its 24 h cache (``ShieldConnectionManager.app_label``), so discovery adds no
|
||||
second naming mechanism.
|
||||
|
||||
Icons are best effort and explicitly heuristic — see ``icon_for_package``.
|
||||
|
||||
MARK — documented, not observed (t62 nit c): the output SHAPES this module parses
|
||||
— ``pm list packages -3`` printing ``package:<id>`` lines, and
|
||||
``resolve-activity --brief`` printing ``package/activity`` — are Android's
|
||||
documentation, not a captured run. No real Shield was available when discovery was
|
||||
written (t57), and no fixture in this repo is a device capture of either command
|
||||
(``tests/fixtures`` holds activity, wakefulness and media-session dumps instead), so
|
||||
everything below is exercised from the hand-built fixtures in
|
||||
``tests/test_installed_apps.py``. That is why the parsers are deliberately tolerant:
|
||||
an output shape we have never seen degrades to an empty list rather than to a wrong
|
||||
one. Same mark as the state=6 buffering inference — the dataclass note in
|
||||
``ha_adb_common/media_session.py``, echoed in ``media_player.py`` — and as the
|
||||
discovery section of ``connection.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: One `pm list packages` line: ``package:com.example.app``.
|
||||
_PACKAGE_LINE_RE = re.compile(r"^package:(?P<package>[\w.]+)\s*$", re.MULTILINE)
|
||||
|
||||
#: `resolve-activity --brief` prints ``package/activity`` on the last line,
|
||||
#: possibly preceded by the (verbose) intent header.
|
||||
_COMPONENT_RE = re.compile(r"^(?P<package>[\w.]+)/[\w.$]+$", re.MULTILINE)
|
||||
|
||||
#: Activity components that belong to a launcher but are not what the user
|
||||
#: opens: settings/hidden entry points a HOME resolution can land on.
|
||||
_LAUNCHER_NOISE = ("com.android.tv.settings",)
|
||||
|
||||
|
||||
def parse_package_list(output: str | None) -> list[str]:
|
||||
"""The package ids in a ``pm list packages`` dump, in device order.
|
||||
|
||||
Tolerant on purpose: the documented output (see the module MARK — the shape is
|
||||
Android's documentation, not a capture) has one ``package:<id>`` per line, and a
|
||||
shell that prepends noise (adb banners, warnings) must not lose the list.
|
||||
Duplicates are collapsed, order preserved.
|
||||
"""
|
||||
if not output:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
packages: list[str] = []
|
||||
for match in _PACKAGE_LINE_RE.finditer(output):
|
||||
package = match.group("package")
|
||||
if package not in seen:
|
||||
seen.add(package)
|
||||
packages.append(package)
|
||||
return packages
|
||||
|
||||
|
||||
def parse_active_launcher(output: str | None) -> str | None:
|
||||
"""The active HOME package from ``cmd package resolve-activity --brief``.
|
||||
|
||||
Android prints a verbose intent header and then a single
|
||||
``package/activity`` line; only the last component-looking line is the
|
||||
answer. Returns None when nothing resolved (a device with no launcher, or a
|
||||
command that produced no output) — the caller treats that as "no launcher
|
||||
to add" rather than as a failure.
|
||||
"""
|
||||
if not output:
|
||||
return None
|
||||
packages = _COMPONENT_RE.findall(output)
|
||||
for package in reversed(packages):
|
||||
if package not in _LAUNCHER_NOISE:
|
||||
return package
|
||||
return None
|
||||
|
||||
|
||||
def installed_packages(user_output: str | None, launcher_output: str | None) -> list[str]:
|
||||
"""The discovered package ids: user apps first, then the active launcher.
|
||||
|
||||
The launcher goes last so a source list built from this keeps the user's own
|
||||
apps at the top, and it is de-duplicated because a launcher CAN be a
|
||||
third-party package (a replacement TV launcher installed from the store).
|
||||
"""
|
||||
packages = parse_package_list(user_output)
|
||||
launcher = parse_active_launcher(launcher_output)
|
||||
if launcher and launcher not in packages:
|
||||
packages.append(launcher)
|
||||
return packages
|
||||
|
||||
|
||||
def icon_for_package(package: str, curated: dict[str, str] | None = None,
|
||||
generic: str = "mdi:television") -> str:
|
||||
"""A best-effort MDI icon for a package, never a non-string (t57).
|
||||
|
||||
HEURISTIC, and labelled as such: the icon is looked up in a curated
|
||||
package -> MDI map, and anything unknown gets the generic fallback. There is
|
||||
no device query behind this because there cannot be one — ``pm dump`` does
|
||||
not print the app's icon resource, and resolving an extracted APK's bitmap
|
||||
would need code the app itself runs. The user's own icon column (a picture
|
||||
URL or a ``camera.*`` entity) is what ``entity_picture`` consumes, and it
|
||||
outranks this.
|
||||
|
||||
Returns a string in every case: the UI is never handed None or a dict.
|
||||
"""
|
||||
if not package:
|
||||
return generic
|
||||
table = curated if curated is not None else {}
|
||||
return table.get(package) or generic
|
||||
|
||||
|
||||
def is_launchable(package: str) -> bool:
|
||||
"""Cheap sanity filter for a package id.
|
||||
|
||||
Not a real Android query: a package id is dotted and non-empty. The filter
|
||||
exists so a malformed line cannot become a table row or a source entry.
|
||||
"""
|
||||
return bool(package) and "." in package and all(
|
||||
part.isalnum() or part in ("_", "-") for part in package.split(".")
|
||||
)
|
||||
@@ -17,6 +17,14 @@ class MediaSessionInfo:
|
||||
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
|
||||
wallclock value. Use :meth:`position_at_uptime` with the device's current
|
||||
uptime to extrapolate the live position.
|
||||
|
||||
``playing``/``paused``/``buffering`` are independent flags mapped from the
|
||||
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
|
||||
(t55). 6 is the stock-Android buffering value and the mapping is INFERRED
|
||||
from the ``state=PlaybackState {state=N, ...}`` line shape, not from a
|
||||
captured Shield dump: no fixture in this repo contains state=6, and no
|
||||
real device was available. Nothing here is vendor-specific either way —
|
||||
every integer is read straight out of the dump.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
@@ -25,6 +33,7 @@ class MediaSessionInfo:
|
||||
package: str | None = None
|
||||
playing: bool = False
|
||||
paused: bool = False
|
||||
buffering: bool = False
|
||||
position: float | None = None
|
||||
speed: float | None = None
|
||||
updated: int | None = None
|
||||
@@ -81,7 +90,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
"""Parse `dumpsys media_session` output for ALL active media sessions.
|
||||
|
||||
Returns a list of every active session that has metadata or a known
|
||||
playback state, sorted: playing first, then paused, then others.
|
||||
playback state, sorted: playing first, then buffering, then paused, then
|
||||
others (t55).
|
||||
"""
|
||||
if not dump:
|
||||
return []
|
||||
@@ -89,7 +99,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
sessions: list[MediaSessionInfo] = []
|
||||
current_pkg: str | None = None
|
||||
in_active = False
|
||||
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
||||
# Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering
|
||||
current_state = 0
|
||||
current_position: float | None = None
|
||||
current_speed: float | None = None
|
||||
current_updated: int | None = None
|
||||
@@ -138,6 +149,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
info = MediaSessionInfo()
|
||||
info.package = current_pkg
|
||||
info.playing = current_state == 3
|
||||
info.buffering = current_state == 6
|
||||
info.paused = current_state == 2
|
||||
info.position = current_position
|
||||
info.speed = current_speed
|
||||
@@ -158,13 +170,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
current_meta.package = current_pkg
|
||||
sessions.append(current_meta)
|
||||
|
||||
# Sort: playing first, then paused, then others
|
||||
# Sort: playing first, then buffering (mid-playback), then paused, then
|
||||
# others — a buffering session outranks a paused one (t55).
|
||||
def sort_key(s: MediaSessionInfo) -> int:
|
||||
if s.playing:
|
||||
return 0
|
||||
if s.paused:
|
||||
if s.buffering:
|
||||
return 1
|
||||
if s.paused:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
sessions.sort(key=sort_key)
|
||||
return sessions
|
||||
@@ -206,8 +221,9 @@ def parse_media_session(
|
||||
Returns the best match from all active sessions:
|
||||
1. The session whose package matches the foreground app (if provided)
|
||||
2. The session that is currently playing (state=3)
|
||||
3. The session that is paused (state=2)
|
||||
4. Any active session with metadata
|
||||
3. The session that is buffering (state=6)
|
||||
4. The session that is paused (state=2)
|
||||
5. Any active session with metadata
|
||||
"""
|
||||
sessions = parse_all_media_sessions(dump)
|
||||
if not sessions:
|
||||
@@ -218,6 +234,5 @@ def parse_media_session(
|
||||
for s in sessions:
|
||||
if s.package == foreground_package:
|
||||
return s
|
||||
|
||||
# Already sorted: playing > paused > other
|
||||
# Already sorted: playing > buffering > paused > other
|
||||
return sessions[0]
|
||||
@@ -51,6 +51,8 @@ def running_apps_from_tasks(
|
||||
if session:
|
||||
if session.playing:
|
||||
app["state"] = "playing"
|
||||
elif session.buffering:
|
||||
app["state"] = "buffering"
|
||||
elif session.paused:
|
||||
app["state"] = "paused"
|
||||
else:
|
||||
@@ -66,6 +68,8 @@ def running_apps_from_tasks(
|
||||
if cached is not None:
|
||||
if cached.playing:
|
||||
app["last_known_state"] = "playing"
|
||||
elif cached.buffering:
|
||||
app["last_known_state"] = "buffering"
|
||||
elif cached.paused:
|
||||
app["last_known_state"] = "paused"
|
||||
if cached.title:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "2026.10.11.2",
|
||||
"version": "2026.10.12",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
|
||||
@@ -13,6 +13,48 @@ State architecture:
|
||||
keyevent. The external androidtv_remote entity tier that used to back this is
|
||||
gone — t41 removed it with the entity — so a command has exactly one owner.
|
||||
|
||||
State ladder (t55) — what ``state`` reports, in order. The vocabulary is
|
||||
EXACTLY ``{playing, paused, buffering, idle, off, on}``; each step is the first
|
||||
source that has something to say, and the earlier steps always win:
|
||||
|
||||
1. OFF — the configured power entity reads "off", or the protocol's
|
||||
is_on push is False (the device reporting its own power
|
||||
state). Both OUTRANK media: a stale Jellyfin session must not
|
||||
resurrect playback on a device that is off.
|
||||
2. intent — a play/pause/stop this entity just sent (optimistic UI until
|
||||
a slow Jellyfin poll confirms it; self-heal in 10 s).
|
||||
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
|
||||
4. session — the ADB media session's PlaybackState, including BUFFERING
|
||||
(Android state 6, inferred from the dump's line shape rather
|
||||
than observed on hardware). State 3 → playing, 2 → paused.
|
||||
5. IDLE — ADB says the device is asleep (standby), OR the device is
|
||||
powered on (protocol is_on True / ADB awake / the plug is on)
|
||||
and NOTHING is playing. "Powered on, app open, nothing
|
||||
playing" is IDLE for exactly this reason.
|
||||
6. ON — NOTHING reported a POWER OR PLAYBACK FACT — not "no data" (t58
|
||||
UNIT 0, the t59 F2 ruling, which this wording follows). ON is
|
||||
the "we cannot know" state and the only situation it may
|
||||
describe, so every source that ANSWERS — even to say "not
|
||||
playing" — takes the ladder to IDLE at worst, never here:
|
||||
* a configured Jellyfin entity in ANY state, including idle /
|
||||
off / standby / unavailable (and it reports "not playing"
|
||||
without ever masking a live media session, because the
|
||||
playing/paused tiers above still win);
|
||||
* a live media session that carries no playback flag, i.e. the
|
||||
app publishes a session but is not playing, paused or
|
||||
buffering;
|
||||
* a bare foreground app — the protocol's current-app push or
|
||||
the ADB mResumedActivity parse answered;
|
||||
* a configured power entity, whatever it currently says;
|
||||
* protocol is_on / ADB awake, plugged or not.
|
||||
ON survives for an entry where NONE of those exists: no
|
||||
transport data, no power entity, no Jellyfin entity, no session,
|
||||
no foreground app. It must never mask a playing/paused/buffering
|
||||
source — the rule that fixes the live "On overrides my playing
|
||||
states" report.
|
||||
|
||||
|
||||
|
||||
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||
1. User presses pause → intent recorded → UI shows paused immediately
|
||||
2. Command sent over the serving transport → Shield pauses on screen
|
||||
@@ -36,9 +78,9 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
@@ -88,6 +130,11 @@ SERVICE_TO_STATE = {
|
||||
"media_stop": MediaPlayerState.IDLE,
|
||||
}
|
||||
|
||||
#: t61: the domain a receiver must be in for playback to ENSURE it is on. A
|
||||
#: receiver in any other domain (an AVR media_player) keeps its own semantics —
|
||||
#: input selection and attribute enrichment — and is never switched here.
|
||||
RECEIVER_SWITCH_DOMAIN = "switch"
|
||||
|
||||
_BASE_FEATURES = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
@@ -623,30 +670,75 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _session_state(info) -> MediaPlayerState | None:
|
||||
"""The MediaPlayerState a media session is reporting, or None (t55).
|
||||
|
||||
One place decides what a session means, so ``state`` and
|
||||
``supported_features`` cannot disagree about it. Buffering is checked
|
||||
FIRST: a session refilling its buffer is mid-playback, and state 6 is
|
||||
the stock-Android buffering value (an inference from the dump's line
|
||||
shape — no captured fixture here carries it), so BUFFERING is the
|
||||
more informative of the two if a firmware ever set 6 together with 3.
|
||||
"""
|
||||
if info is None:
|
||||
return None
|
||||
if info.buffering:
|
||||
return MediaPlayerState.BUFFERING
|
||||
if info.playing:
|
||||
return MediaPlayerState.PLAYING
|
||||
if info.paused:
|
||||
return MediaPlayerState.PAUSED
|
||||
return None
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
# Check if plug is off first — truly off
|
||||
"""The playback state — the ladder is documented in the module docstring.
|
||||
|
||||
Exactly ``{playing, paused, buffering, idle, off}`` are reported for a
|
||||
device we have data about; ``ON`` is reserved for the case where NO
|
||||
source reported a POWER OR PLAYBACK FACT — not merely "no transport is
|
||||
connected" (t58 UNIT 0, the t59 F2 ruling). A source that exists and
|
||||
answers "not playing" (Jellyfin idle/off/standby/unavailable, a live
|
||||
media session with no playback flag, a bare foreground app) HAS reported,
|
||||
so the ladder stops at IDLE, never ON. ON must also never mask a
|
||||
playing/paused/buffering source. A powered-on Shield with an app open but
|
||||
nothing playing reads IDLE, not ON (t55 — the live defect users read as
|
||||
"On overrides my playing states").
|
||||
"""
|
||||
# A source that reported a power or playback fact, even a negative one.
|
||||
# ON is the "we cannot know" state, so it survives only when nothing at
|
||||
# all answered — this flag IS the distinction the ruling turns on.
|
||||
reported = False
|
||||
|
||||
# 1. Explicit OFF. The plug being off is definitive; the protocol's
|
||||
# is_on push is the device reporting its own power state. Either
|
||||
# one OUTRANKS every media source — a stale Jellyfin session must
|
||||
# never resurrect playback on a device that is off.
|
||||
if self._power_entity:
|
||||
# a CONFIGURED source counts as reporting whatever it says now,
|
||||
# which is why an unavailable plug yields IDLE rather than ON
|
||||
reported = True
|
||||
power_state = self._read(self._power_entity)
|
||||
if power_state and power_state.state == "off":
|
||||
return MediaPlayerState.OFF
|
||||
manager = self._manager
|
||||
if manager is not None and manager.is_on is False:
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
power = self._is_power_on()
|
||||
|
||||
if power is False:
|
||||
if self._manager is not None and self._manager.is_on is False:
|
||||
return MediaPlayerState.OFF # protocol says powered off
|
||||
# ADB says asleep — standby (yellow dot)
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if power is None:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
# Tier 1: Active intent overrides Jellyfin
|
||||
# 2. A user-commanded intent is the freshest media truth we have.
|
||||
if self._intent is not None:
|
||||
return MediaPlayerState(self._intent.target_state)
|
||||
|
||||
# Tier 2: Jellyfin is authoritative
|
||||
# 3. Jellyfin (slow, but authoritative for its own sessions). A
|
||||
# configured entity reports in EVERY state it can be in: playing and
|
||||
# paused are truth, and idle / off / standby / unavailable / missing
|
||||
# all mean "its session is not playing" — reported, just not
|
||||
# playable, so the ladder continues WITHOUT an early return (a silent
|
||||
# Jellyfin must never mask a live media session) and IDLE is where it
|
||||
# ends (t58 UNIT 0).
|
||||
if self._jellyfin_entity:
|
||||
reported = True
|
||||
jf = self._read(self._jellyfin_entity)
|
||||
if jf:
|
||||
if jf.state == STATE_PLAYING:
|
||||
@@ -654,32 +746,55 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
if jf.state == STATE_PAUSED:
|
||||
return MediaPlayerState.PAUSED
|
||||
|
||||
# Tier 3: ADB media session (non-Jellyfin apps)
|
||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||
if adb_info:
|
||||
if adb_info.playing:
|
||||
return MediaPlayerState.PLAYING
|
||||
if adb_info.paused:
|
||||
return MediaPlayerState.PAUSED
|
||||
# 4. Live media session (non-Jellyfin apps), buffering included. The
|
||||
# session EXISTING is a report even when it carries no playback flag
|
||||
# (an idle or paused app still publishes one): that is IDLE, and it is
|
||||
# the t58 UNIT 0 case that used to fall through to ON.
|
||||
session = manager.best_session(self.app_id) if manager is not None else None
|
||||
if session is not None:
|
||||
reported = True
|
||||
session_state = self._session_state(session)
|
||||
if session_state is not None:
|
||||
return session_state
|
||||
|
||||
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
|
||||
# current_app push, else the ADB mResumedActivity parse) but publishes
|
||||
# no media session. The device is still ON; the now-playing tier
|
||||
# carries the last-known/channel/audio-hint identity.
|
||||
if self._manager is not None and self._manager.foreground_app:
|
||||
return MediaPlayerState.ON
|
||||
# 5. ADB standby: powered but asleep — still data (the device
|
||||
# answered), so IDLE, never ON. media_player.STANDBY is deprecated
|
||||
# (2025-07), so IDLE is the compliant mapping (t22 audit).
|
||||
if manager is not None and manager.awake is False:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
# Tier 4: On but no media
|
||||
return MediaPlayerState.ON
|
||||
# 6. The device is on (protocol is_on True, ADB awake, or the power
|
||||
# plug is on — every one of those is a source that reported
|
||||
# something). An open app or an awake device reads IDLE, not ON.
|
||||
# A bare foreground app is a report in its own right: the protocol's
|
||||
# current-app push or the ADB mResumedActivity parse answered.
|
||||
if self._is_power_on() is not None:
|
||||
reported = True
|
||||
if manager is not None and manager.awake is not None:
|
||||
reported = True
|
||||
# EITHER transport's foreground answer counts: the protocol's
|
||||
# current_app push and the ADB mResumedActivity parse are both
|
||||
# "something reported", even when neither is playing anything.
|
||||
if manager is not None and manager.foreground_app is not None:
|
||||
reported = True
|
||||
|
||||
# 7. Nothing anywhere reported a power or playback fact. ON is the "we
|
||||
# cannot know" state, and the only situation it is allowed to
|
||||
# describe.
|
||||
return MediaPlayerState.IDLE if reported else MediaPlayerState.ON
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
if self._jf_active():
|
||||
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
else:
|
||||
# ADB media session also supports playback controls
|
||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||
if adb_info and (adb_info.playing or adb_info.paused):
|
||||
# A media session's playback controls include the BUFFERING case:
|
||||
# pausing/resuming a buffering stream must be offered exactly like
|
||||
# PLAYING (t55).
|
||||
session = (
|
||||
self._manager.best_session(self.app_id) if self._manager else None
|
||||
)
|
||||
if session is not None and self._session_state(session) is not None:
|
||||
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
else:
|
||||
features = _BASE_FEATURES
|
||||
@@ -718,8 +833,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
def _app_directory(self) -> dict:
|
||||
"""Launchable apps: the user's map, else the curated default (P4-1).
|
||||
|
||||
ONE source for the browse tree, the source list and the reverse lookup, so
|
||||
the dropdown can never offer a name the launch path cannot resolve.
|
||||
ONE source for the source list and the reverse lookup (t61 removed the
|
||||
browse tree that used to be the third consumer), so the dropdown can
|
||||
never offer a name the launch path cannot resolve.
|
||||
|
||||
The map is hand-editable, so it is NORMALIZED here — once, so that every
|
||||
consumer sees the same thing (t51):
|
||||
@@ -729,9 +845,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
"TiviMate", instead of being shown padded and missing every lookup;
|
||||
* an entry whose name — or whose package — is blank/whitespace-only is
|
||||
dropped, so no surface can offer a child with no package behind it or a
|
||||
name no launch can reach (the t48 invariant, extended to the tree);
|
||||
* two packages normalizing to the same name are BOTH KEPT — the browse
|
||||
tree reaches each by its own package id, so neither app is hidden — while
|
||||
name no launch can reach (the t48 invariant, extended to the table);
|
||||
* two packages normalizing to the same name are BOTH KEPT — each stays
|
||||
reachable by its own package id, which ``select_source`` accepts (t61:
|
||||
the browse tree that used to publish those ids is gone) — while
|
||||
the FIRST in map order wins the name: that is the single entry
|
||||
source_list offers and the package select_source resolves. Map order is
|
||||
the user's order, so the winner does not move between rebuilds. "The same
|
||||
@@ -760,8 +877,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
Directory order (the user's map order, or the curated list), so rebuilding
|
||||
the page does not shuffle it. Blanks are already gone and names already
|
||||
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
|
||||
one accessor so the dropdown, the browse tree and the reverse lookup cannot
|
||||
disagree.
|
||||
one accessor so the dropdown and the reverse lookup cannot disagree.
|
||||
|
||||
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
|
||||
first spelling the user typed. Exact duplicates were already collapsed, but
|
||||
@@ -769,8 +885,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
|
||||
of them to the first entry, so the second row launched the wrong app. One
|
||||
row per name means a row can only launch the app it shows. The shadowed
|
||||
package is not lost: the browse tree still lists it under its own id, and
|
||||
select_source accepts that id.
|
||||
package is not lost: ``select_source`` accepts its own package id.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
offered: list[str] = []
|
||||
@@ -902,6 +1017,21 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
np["media_info"] = self._media_info
|
||||
return np
|
||||
|
||||
def _session_playback_label(self, info) -> str:
|
||||
"""``playing``/``buffering``/``paused``/``idle`` for a live session (t55).
|
||||
|
||||
BUFFERING is its own label rather than a flavour of playing: the state
|
||||
entity says so, and the now-playing dict must not contradict it. The
|
||||
session flags themselves stay independent; this only names the state.
|
||||
"""
|
||||
if info.buffering:
|
||||
return "buffering"
|
||||
if info.playing:
|
||||
return "playing"
|
||||
if info.paused:
|
||||
return "paused"
|
||||
return "idle"
|
||||
|
||||
def _get_adb_now_playing(self) -> dict | None:
|
||||
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
|
||||
info = self._manager.best_session(self.app_id) if self._manager else None
|
||||
@@ -909,9 +1039,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
return None
|
||||
if not self._adb_store_info:
|
||||
# Privacy mode: show that something is playing but not what
|
||||
if info.playing or info.paused:
|
||||
if info.playing or info.paused or info.buffering:
|
||||
return {
|
||||
"state": "playing" if info.playing else "paused",
|
||||
"state": self._session_playback_label(info),
|
||||
"title": self.app_name or self.app_id or "Playing",
|
||||
"source": "media_session",
|
||||
}
|
||||
@@ -920,7 +1050,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
if not info.title:
|
||||
return None
|
||||
np = {
|
||||
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
|
||||
"state": self._session_playback_label(info),
|
||||
"title": info.title,
|
||||
"source": "media_session",
|
||||
}
|
||||
@@ -934,6 +1064,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
|
||||
no media session (TiviMate, R2). Title = last known state, else the
|
||||
last channel entered, else the zero-cost audio-playback hint.
|
||||
|
||||
The STATE here is ``idle`` or the last-known session's playback state;
|
||||
the entity-level ladder reports IDLE for this tier (t55), so the two
|
||||
agree — a bare foreground app is no longer reported as ON.
|
||||
"""
|
||||
manager = self._manager
|
||||
if manager is None:
|
||||
@@ -945,8 +1079,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
state = "idle"
|
||||
title = None
|
||||
info = manager.last_known_session(foreground)
|
||||
if info is not None and (info.playing or info.paused):
|
||||
state = "playing" if info.playing else "paused"
|
||||
if info is not None and (info.playing or info.paused or info.buffering):
|
||||
state = self._session_playback_label(info)
|
||||
# Privacy mode: identity stays the app, never the media title
|
||||
title = None if not self._adb_store_info else info.title
|
||||
if title is None and not self._adb_store_info:
|
||||
@@ -963,7 +1097,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
# E hint (zero round-trip cost): audio was recently played
|
||||
title = "Media recently active"
|
||||
if title is None:
|
||||
return None # bare foreground app — the state stays ON regardless
|
||||
# Bare foreground app: no now-playing identity, but the device is
|
||||
# ON — the entity ladder reports IDLE for this (t55).
|
||||
return None
|
||||
return {"state": state, "title": title, "source": "app"}
|
||||
|
||||
def _get_power_data(self) -> dict | None:
|
||||
@@ -1081,8 +1217,67 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
self.hass, self._entry.options, command, variables
|
||||
)
|
||||
|
||||
async def _ensure_receiver_on(self) -> None:
|
||||
"""Turn a SWITCH receiver on before playback — only when it is off (t61).
|
||||
|
||||
The user's request, verbatim: "it would be nice if a receiver could just
|
||||
be a switch, I don't need my volume scripts then because the receiver
|
||||
portion could just make sure the receiver is on if it's a switch". So a
|
||||
``switch.*`` receiver is ENSURED ON by the playback entry points
|
||||
(:meth:`async_turn_on`, :meth:`async_play_media`,
|
||||
:meth:`async_select_source`) and nothing else happens to it: no volume,
|
||||
no toggle, no other switch semantics.
|
||||
|
||||
Deliberately narrow, in four ways:
|
||||
|
||||
* only a ``switch.*`` receiver is touched — a ``media_player.*``
|
||||
receiver keeps its behaviour exactly;
|
||||
* only when the switch reports ``off``. An ``on`` switch issues no
|
||||
service call at all, so this is idempotent — a state write, a poll or
|
||||
a second entry point in the same action never re-calls it. A state we
|
||||
cannot read (missing entity, ``unavailable``, ``unknown``) is left
|
||||
alone rather than guessed at, with a debug line saying so;
|
||||
* never called from a state read, a poll, a listener or attribute
|
||||
rendering, so a receiver that is off while the Shield is idle stays
|
||||
off — this is a playback action, not a synchronisation;
|
||||
* a service failure (the entity vanished between the read and the call)
|
||||
is logged, not raised: a receiver that cannot be switched on must not
|
||||
stop the Shield from playing.
|
||||
"""
|
||||
entity_id = self._receiver_entity
|
||||
if not entity_id or not entity_id.startswith(f"{RECEIVER_SWITCH_DOMAIN}."):
|
||||
return
|
||||
state = self._read(entity_id)
|
||||
if state is None:
|
||||
_LOGGER.debug(
|
||||
"receiver %s is not in the state machine — not switching it",
|
||||
entity_id,
|
||||
)
|
||||
return
|
||||
if state.state != STATE_OFF:
|
||||
if state.state != STATE_ON:
|
||||
_LOGGER.debug(
|
||||
"receiver %s reports %r — not switching it", entity_id, state.state
|
||||
)
|
||||
return
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
RECEIVER_SWITCH_DOMAIN,
|
||||
"turn_on",
|
||||
{"entity_id": entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.debug("receiver %s could not be turned on: %s", entity_id, err)
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Wake the Shield (only when it reports OFF) — or run the turn_on script."""
|
||||
"""Wake the Shield (only when it reports OFF) — or run the turn_on script.
|
||||
|
||||
A ``switch.*`` receiver is ensured on first (t61): the receiver is where
|
||||
the Shield's sound comes out, so a playback entry point that wakes the
|
||||
Shield wakes it too — and only when it is actually off.
|
||||
"""
|
||||
await self._ensure_receiver_on()
|
||||
if await self._script_served("turn_on"):
|
||||
return
|
||||
await self._power_key(send_when_on=False)
|
||||
@@ -1136,8 +1331,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
if not wanted:
|
||||
return None
|
||||
if wanted in directory:
|
||||
# an automation may hand us the package directly; that is what the
|
||||
# browse tree publishes as media_content_id
|
||||
# an automation may hand us the package directly; that is the id
|
||||
# this table is keyed by (t61: the browse tree that used to publish
|
||||
# it is gone, the id itself is unchanged)
|
||||
return wanted
|
||||
folded = wanted.casefold()
|
||||
for package, name in directory.items():
|
||||
@@ -1154,7 +1350,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
directory the source list came from and launched over the shared
|
||||
``play_media("app", …)`` path, so a universal-media-player replacement gets
|
||||
the native behaviour. An unknown name is an error, never a silent no-op.
|
||||
|
||||
A ``switch.*`` receiver is ensured on first (t61), before the script: a
|
||||
source selection is the start of playback, and it dispatches internally
|
||||
so the switch is looked at exactly once per user action.
|
||||
"""
|
||||
await self._ensure_receiver_on()
|
||||
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
|
||||
return
|
||||
package = self._source_package(source)
|
||||
@@ -1164,7 +1365,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
translation_key="unknown_source",
|
||||
translation_placeholders={"source": str(source)},
|
||||
)
|
||||
await self.async_play_media(MediaType.APP, package)
|
||||
await self._dispatch_play_media(MediaType.APP, package)
|
||||
|
||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||
@@ -1243,45 +1444,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
# ── BrowseMedia app directory (P4-2c) ──
|
||||
|
||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||
"""Root lists the apps map (or the curated default) → play_media(app).
|
||||
"""The media browser is EMPTY on purpose — apps live in the sources (t61).
|
||||
|
||||
t37 D1: the standard media_player tree, exactly as core's own
|
||||
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
|
||||
homeassistant.components.media_player, every node carrying an explicit
|
||||
media_content_id/media_content_type, and the directory naming the
|
||||
class of its children. ``BrowseMediaSource`` only ever existed in
|
||||
homeassistant.components.media_source.models (t30 G18's domain
|
||||
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
|
||||
so an app-directory node must never claim one — and the import above
|
||||
removed a name media_player has not exported for a long time, which
|
||||
made every real browse raise.
|
||||
The user's report, verbatim: "Media Browser shows Apps. To me it should
|
||||
probably be integrated with the home assistant media display, keep
|
||||
select sources for apps". They are right about the surface: HA's media
|
||||
browser is for BROWSABLE media, while those rows are SOURCES. They stay
|
||||
where a source belongs — ``source_list``/``source``, the source
|
||||
dropdown, ``select_source`` and ``play_media("app", …)`` — and that
|
||||
offer is complete.
|
||||
|
||||
So this returns a valid, standard ``media_player.BrowseMedia`` root with
|
||||
NO children rather than a second, inconsistent copy of the app list:
|
||||
``BrowseMedia`` (never ``BrowseMediaSource``, which media_player has not
|
||||
exported since before t37 D1), the device as the root title, a directory
|
||||
class, and no content id. There is no node to select, so no dead entry
|
||||
can be offered and a click can never reach a row that cannot launch. An
|
||||
integration with genuinely browsable media would publish it here; this
|
||||
one has none.
|
||||
"""
|
||||
app_map = self._app_directory
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=name,
|
||||
media_class=MediaClass.APP,
|
||||
media_content_type=MediaType.APP,
|
||||
media_content_id=pkg,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
for pkg, name in app_map.items()
|
||||
]
|
||||
return BrowseMedia(
|
||||
title="Apps",
|
||||
title=self._entry.title or "NVIDIA Shield",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaType.APPS,
|
||||
media_content_id="apps",
|
||||
children_media_class=MediaClass.APP,
|
||||
media_content_id=None,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
can_expand=False,
|
||||
children=[],
|
||||
)
|
||||
|
||||
# ── play_media (§7): channel digits, app/url launch ──
|
||||
|
||||
async def async_play_media(self, media_type: str, media_id: str) -> None:
|
||||
"""HA's ``play_media`` entry point — ensure the receiver, then dispatch.
|
||||
|
||||
The receiver ensure (t61) lives on the PUBLIC path only, so an internal
|
||||
caller (:meth:`async_select_source`) dispatches without a second look at
|
||||
the same switch inside one user action.
|
||||
"""
|
||||
await self._ensure_receiver_on()
|
||||
await self._dispatch_play_media(media_type, media_id)
|
||||
|
||||
async def _dispatch_play_media(self, media_type: str, media_id: str) -> None:
|
||||
"""Channel selection by digit number; app/url launches (C-LAUNCH).
|
||||
|
||||
Channel path replicates HA core androidtv_remote exactly: digits only,
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
DOMAIN,
|
||||
@@ -28,6 +29,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Nvidia Shield sensor entities."""
|
||||
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
||||
if entry.options.get(CONF_ADB_ENABLED, True):
|
||||
# discovery is an ADB feature; with the transport off there is nothing
|
||||
# to enumerate and the entity would only ever report "unavailable"
|
||||
entities.append(NvidiaShieldAppsSensor(hass, entry))
|
||||
|
||||
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
||||
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
||||
@@ -41,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
# G12 (t30): poll-driven attributes never belong in the database
|
||||
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
|
||||
_unrecorded_attributes = frozenset(
|
||||
{"app_id", "app_name", "playback_state", "installed_apps"}
|
||||
)
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
@@ -93,10 +100,13 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _package(self) -> str | None:
|
||||
"""The raw foreground package from the manager's store (t41).
|
||||
"""The raw package of the app that is on screen (t41, t55).
|
||||
|
||||
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
||||
mResumedActivity → ADB recents.
|
||||
mResumedActivity (both via ``manager.foreground_app``) → the most
|
||||
recent visible ADB recent task. The recents tier is a last resort for
|
||||
a moment when neither live signal has reported yet; it keeps the
|
||||
sensor from going None while ADB is still warming up.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
@@ -117,6 +127,13 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
``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.
|
||||
|
||||
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
|
||||
demand for exactly the package passed here, so a live run showing a
|
||||
package id means the label tier never answered — see
|
||||
``ShieldConnectionManager.app_label`` for the caching rule that makes
|
||||
a miss retry instead of being stamped as an eternal "no label".
|
||||
"""
|
||||
manager = self._manager()
|
||||
return resolve_app_name(
|
||||
@@ -138,6 +155,105 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
return attrs
|
||||
|
||||
|
||||
class NvidiaShieldAppsSensor(NvidiaShieldSensorBase):
|
||||
"""The apps installed on the Shield (t57) — a dashboard-facing surface.
|
||||
|
||||
The state is the app COUNT and the ``installed_apps`` attribute is the
|
||||
table: ``[{package, name, icon}, ...]``, exactly what the Configure page's
|
||||
Apps sub-screen edits. It is the answer to "it doesn't find all the apps":
|
||||
the list comes from the device (``pm list packages -3`` plus the active
|
||||
launcher), labelled through the cached ``pm dump`` path.
|
||||
|
||||
Only ADB-enabled entries get one. Discovery is a handful of shells, so it
|
||||
runs once when the entity is added and then only when the table has gone
|
||||
stale: ``async_update`` goes through the manager's TTL cache, so a poll on a
|
||||
fresh table issues NO shell. A sweep that could not run is NOT an answer
|
||||
(t62 F1): it is recorded as ``unknown`` — or shown as ``not_connected``
|
||||
while the transport itself is down — and retried on the next update instead
|
||||
of being frozen as a stamped-empty table for APP_LIST_TTL, while a
|
||||
sweep that ran and found nothing IS the answer ``ok`` with a count of 0. On
|
||||
top of that the user can ask for a refresh with the standard
|
||||
``homeassistant.update_entity`` action, and the Configure page runs the same
|
||||
sweep from its discovery row. With ADB off, or unreachable, the attributes
|
||||
say why and the state is 0 rather than raising.
|
||||
"""
|
||||
|
||||
_attr_translation_key = "installed_apps"
|
||||
_attr_icon = "mdi:apps"
|
||||
# Polling is what makes the standard `homeassistant.update_entity` action
|
||||
# work for it — the user-facing "refresh the app list" trigger. The poll is
|
||||
# free while the table is fresh: the manager returns the cache and issues no
|
||||
# shell until APP_LIST_TTL expires. A sweep that COULD NOT run leaves no
|
||||
# fresh table (t62 F1), so a later poll retries it — that retry is the
|
||||
# recovery the old stamped-empty answer blocked for up to six hours.
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps"
|
||||
|
||||
def _manager(self) -> ShieldConnectionManager | None:
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||
# one discovery per entity lifetime; a load is already a wake-up moment,
|
||||
# and nothing here runs on a state poll
|
||||
self.hass.async_create_task(self.async_update())
|
||||
|
||||
@callback
|
||||
def _handle_store_update(self) -> None:
|
||||
if self.hass is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Refresh the table when there is no fresh answer (t57, t62 F1).
|
||||
|
||||
HA's own poll (``_attr_should_poll``) and an explicit
|
||||
``homeassistant.update_entity`` both land here; the manager's TTL makes
|
||||
it a no-op while the table is fresh, and a retry while it is not —
|
||||
including after a sweep that could not run.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
return
|
||||
await manager.async_refresh_installed_apps()
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
manager = self._manager()
|
||||
return len(manager.installed_apps()) if manager is not None else 0
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The table itself, plus why it may be empty (never a crash).
|
||||
|
||||
``discovery`` is the honest reason (t62 F1): ``ok`` means the table IS
|
||||
the answer, including a genuinely empty one, while ``unknown`` means
|
||||
the last sweep could not run — so an empty list says nothing about the
|
||||
device and the next update retries.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
return {"installed_apps": [], "discovery": "unavailable"}
|
||||
apps = manager.installed_apps()
|
||||
attrs = {"installed_apps": apps}
|
||||
if apps:
|
||||
attrs["discovery"] = "ok"
|
||||
elif not self._entry.options.get(CONF_ADB_ENABLED, True):
|
||||
attrs["discovery"] = "adb_disabled"
|
||||
elif not manager.connected:
|
||||
attrs["discovery"] = "not_connected"
|
||||
else:
|
||||
# "ok" here is a real empty answer, "unknown" a sweep that could
|
||||
# not run, "pending" one that has not completed yet.
|
||||
attrs["discovery"] = manager.installed_apps_status
|
||||
return attrs
|
||||
|
||||
|
||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||
"""What's currently playing via Jellyfin."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source — the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
@@ -18,33 +18,84 @@
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Finish setup",
|
||||
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
},
|
||||
"menu_options": {
|
||||
"receiver": "Receiver",
|
||||
"connection": "Connection",
|
||||
"adb_pair": "Set up ADB",
|
||||
"protocol_pair": "Pair the remote protocol",
|
||||
"finish": "Create the entry"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection",
|
||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if the Shield moved or you gave it a different address.",
|
||||
"adb_port": "The standard ADB port is 5555.",
|
||||
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||
}
|
||||
},
|
||||
"protocol_pair": {
|
||||
"title": "Pair the remote protocol",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"host": "The Shield's IP address",
|
||||
@@ -52,13 +103,13 @@
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-pair Nvidia Shield",
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -67,9 +118,9 @@
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"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 \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"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 \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
@@ -77,7 +128,7 @@
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||
"yaml_seed_only": {},
|
||||
@@ -91,7 +142,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NVIDIA Shield settings",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||
"data": {
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
@@ -117,21 +168,98 @@
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing — use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it — the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) — 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||
},
|
||||
"menu_options": {
|
||||
"connection": "Main setup",
|
||||
"receiver": "Receiver",
|
||||
"apps": "Apps",
|
||||
"discover": "Discover installed apps",
|
||||
"scripts": "Scripts",
|
||||
"finish": "Save and close"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Main setup",
|
||||
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"logcat_events": "Watch app launches over logcat",
|
||||
"adb_command_enabled": "Allow the adb_command action",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"power_entity": "Power switch"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
"data_description": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"remote_command_script": "Script for remote.send_command",
|
||||
"script_turn_on": "turn_on script",
|
||||
"script_turn_off": "turn_off script",
|
||||
"script_volume_up": "volume_up script",
|
||||
"script_volume_down": "volume_down script",
|
||||
"script_volume_set": "volume_set script",
|
||||
"script_volume_mute": "volume_mute script",
|
||||
"script_select_source": "select_source script",
|
||||
"channel_digit_delay": "Delay between channel digits (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits"
|
||||
},
|
||||
"data_description": {
|
||||
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,19 +283,19 @@
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty \u2014 nothing was sent."
|
||||
"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) \u2014 command not sent."
|
||||
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent."
|
||||
},
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
"message": "The ADB connection of {entity_id} is down — command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list — the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
@@ -181,6 +309,9 @@
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source — the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
@@ -18,33 +18,84 @@
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Finish setup",
|
||||
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
},
|
||||
"menu_options": {
|
||||
"receiver": "Receiver",
|
||||
"connection": "Connection",
|
||||
"adb_pair": "Set up ADB",
|
||||
"protocol_pair": "Pair the remote protocol",
|
||||
"finish": "Create the entry"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection",
|
||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if the Shield moved or you gave it a different address.",
|
||||
"adb_port": "The standard ADB port is 5555.",
|
||||
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||
}
|
||||
},
|
||||
"protocol_pair": {
|
||||
"title": "Pair the remote protocol",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"host": "The Shield's IP address",
|
||||
@@ -52,13 +103,13 @@
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-pair Nvidia Shield",
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -67,9 +118,9 @@
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"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 \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"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 \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
@@ -77,7 +128,7 @@
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||
"yaml_seed_only": {},
|
||||
@@ -91,7 +142,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NVIDIA Shield settings",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||
"data": {
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
@@ -117,21 +168,98 @@
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing — use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it — the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) — 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||
},
|
||||
"menu_options": {
|
||||
"connection": "Main setup",
|
||||
"receiver": "Receiver",
|
||||
"apps": "Apps",
|
||||
"discover": "Discover installed apps",
|
||||
"scripts": "Scripts",
|
||||
"finish": "Save and close"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Main setup",
|
||||
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"logcat_events": "Watch app launches over logcat",
|
||||
"adb_command_enabled": "Allow the adb_command action",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"power_entity": "Power switch"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"app_name_map": "App names and icons"
|
||||
},
|
||||
"data_description": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"remote_command_script": "Script for remote.send_command",
|
||||
"script_turn_on": "turn_on script",
|
||||
"script_turn_off": "turn_off script",
|
||||
"script_volume_up": "volume_up script",
|
||||
"script_volume_down": "volume_down script",
|
||||
"script_volume_set": "volume_set script",
|
||||
"script_volume_mute": "volume_mute script",
|
||||
"script_select_source": "select_source script",
|
||||
"channel_digit_delay": "Delay between channel digits (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits"
|
||||
},
|
||||
"data_description": {
|
||||
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,19 +283,19 @@
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty \u2014 nothing was sent."
|
||||
"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) \u2014 command not sent."
|
||||
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent."
|
||||
},
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
"message": "The ADB connection of {entity_id} is down — command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list — the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
@@ -181,6 +309,9 @@
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-3
@@ -67,9 +67,18 @@ def install_selector_faces(selector_module) -> None:
|
||||
installers in the flow tests), so the stubs cannot drift apart.
|
||||
"""
|
||||
selector_module.EntitySelector = EntitySelectorStub
|
||||
selector_module.EntitySelectorConfig = lambda *args, **kwargs: ""
|
||||
|
||||
class _Config(types.SimpleNamespace):
|
||||
"""Selector configs keep their kwargs (real HA stores them as fields).
|
||||
|
||||
The stub needs them inspectable: which domains a selector offers is a
|
||||
user-visible contract (t56 — the Receiver field accepts a switch), and a
|
||||
lambda throwing the value away cannot pin it.
|
||||
"""
|
||||
|
||||
selector_module.EntitySelectorConfig = _Config
|
||||
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||
selector_module.SelectSelectorConfig = lambda **kwargs: ""
|
||||
selector_module.SelectSelectorConfig = _Config
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
|
||||
|
||||
@@ -79,6 +88,8 @@ class MediaPlayerState(enum.StrEnum):
|
||||
IDLE = "idle"
|
||||
PLAYING = "playing"
|
||||
PAUSED = "paused"
|
||||
STANDBY = "standby"
|
||||
BUFFERING = "buffering"
|
||||
|
||||
|
||||
class MediaPlayerEntityFeature(enum.IntFlag):
|
||||
@@ -387,6 +398,10 @@ def install() -> None:
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.STATE_PLAYING = "playing"
|
||||
ha_const.STATE_PAUSED = "paused"
|
||||
# t61: a receiver switch is read and switched by the media player, so the
|
||||
# two switch states have to exist with their real values.
|
||||
ha_const.STATE_ON = "on"
|
||||
ha_const.STATE_OFF = "off"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
@@ -598,8 +613,43 @@ def load_entity_modules() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class FakeState:
|
||||
"""The fields entity code actually reads off an HA ``State`` (t61)."""
|
||||
|
||||
def __init__(self, entity_id, state, attributes=None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
|
||||
class FakeStates:
|
||||
"""A dict-backed stand-in for HA's StateMachine (t61).
|
||||
|
||||
Only the read shape entities use: ``get(entity_id)`` returns the state
|
||||
object, or None for an entity that does not exist — exactly what the real
|
||||
machine returns. ``set`` is a test's way of giving an entity a state.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._states: dict = {}
|
||||
|
||||
def set(self, entity_id, state, attributes=None):
|
||||
self._states[entity_id] = FakeState(entity_id, state, attributes)
|
||||
|
||||
def get(self, entity_id):
|
||||
return self._states.get(entity_id)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later.
|
||||
|
||||
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
||||
code that reads a linked entity works unmodified; it is empty until a test
|
||||
sets something.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.states = FakeStates()
|
||||
|
||||
def async_create_task(self, coro):
|
||||
return asyncio.create_task(coro)
|
||||
|
||||
+113
-13
@@ -35,7 +35,8 @@ dependency against an INSTALLED homeassistant:
|
||||
(``extra_state_attributes``, ``available``, …) declared as properties — HA
|
||||
merges them with ``attr |= self.extra_state_attributes``, so a plain method
|
||||
raises ``TypeError: 'method' object is not iterable`` on every state write and
|
||||
the entity can never render (t40 live install: ``media_player.py:828``). The
|
||||
the entity can never render (t40 live install: ``media_player.py``'s
|
||||
``extra_state_attributes`` — a property today, so the number rotted). The
|
||||
stub suites call the method happily; this pass is pure AST and, unlike 1-3,
|
||||
needs no homeassistant at all;
|
||||
5. the package's OWN relative imports (t41): ``from .helpers import X`` must
|
||||
@@ -44,10 +45,16 @@ dependency against an INSTALLED homeassistant:
|
||||
harness loaded that file the ImportError waited for a user's log — pass 1
|
||||
cannot see it (it only judges ``homeassistant.*``). Also pure AST, with its
|
||||
own entry point ``scan_package_imports()`` for the fast suite.
|
||||
6. unreachable statements (t62 F2): a statement after
|
||||
``return``/``raise``/``continue``/``break`` in the SAME block can never run.
|
||||
``connection.py`` carried a full duplicated copy of the label-drain body
|
||||
(docstring included) after its ``return cached`` — valid Python, dead, and
|
||||
invisible to every other pass here, so it shipped. Pure AST, with its own
|
||||
entry point ``scan_unreachable()``.
|
||||
|
||||
Both AST passes run before the homeassistant availability check, and the file
|
||||
All three AST passes run before the homeassistant availability check, and the file
|
||||
walk is recursive (``_iter_python_files``), so a platform or helper one
|
||||
directory down cannot hide from either of them.
|
||||
directory down cannot hide from any of them.
|
||||
|
||||
Known limits (deliberate, and why the sweep still catches the real classes):
|
||||
``**kwargs`` splats cannot be checked statically, calls on objects other than
|
||||
@@ -96,8 +103,16 @@ SHIELD_DIR = (
|
||||
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
# t57 UNIT 0a: the FLOW-DISPLAY API surface. These are the calls that decide
|
||||
# what the user sees, and a wrong keyword here is not a style error — it is a
|
||||
# TypeError the moment a user submits a form, on every path through it.
|
||||
# `async_show_menu` takes NO `errors` (HA 2026.10.1: step_id, menu_options,
|
||||
# sort, description_placeholders), which is precisely the bug this guards.
|
||||
"async_show_menu": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||
"async_show_progress": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||
"async_show_progress_done": ("homeassistant.data_entry_flow", "FlowHandler"),
|
||||
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
|
||||
"_abort_if_unique_id_configured": (
|
||||
"homeassistant.config_entries",
|
||||
@@ -112,17 +127,28 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||
}
|
||||
|
||||
#: 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 (13 modules,
|
||||
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or
|
||||
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
|
||||
#: 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
|
||||
#: printing zeros and exiting 0.
|
||||
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,
|
||||
"call_args": 20,
|
||||
# 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
|
||||
# looked green — only the pre-fix fixture tripped it. At 44 every owner that
|
||||
# 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,
|
||||
"awaited_calls": 5,
|
||||
# t62 F2: the unreachable-statement pass walks ~1100 statement blocks. A
|
||||
# floor here means a pass that silently stopped parsing cannot look green.
|
||||
"statement_blocks": 500,
|
||||
}
|
||||
|
||||
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||
@@ -159,7 +185,7 @@ class Report:
|
||||
relative_imports: int = 0
|
||||
call_args: int = 0
|
||||
awaited_calls: int = 0
|
||||
|
||||
statement_blocks: int = 0
|
||||
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
|
||||
"""Counters below the non-vacuity floors — a sweep nobody should trust.
|
||||
|
||||
@@ -179,6 +205,7 @@ class Report:
|
||||
"no Home-Assistant API drift "
|
||||
f"({self.modules} modules, {self.imports} imports, "
|
||||
f"{self.relative_imports} relative imports, "
|
||||
f"{self.statement_blocks} statement blocks, "
|
||||
f"{self.call_args} call arguments, "
|
||||
f"{self.awaited_calls} awaited HA calls)"
|
||||
)
|
||||
@@ -682,7 +709,8 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||
#: HA: ``Entity.__async_calculate_state`` merges ``extra_state_attributes`` with
|
||||
#: ``attr |= self.extra_state_attributes``, which raises
|
||||
#: ``TypeError: 'method' object is not iterable`` on every state write. That was
|
||||
#: the t40 live-install blocker (``media_player.py:828``).
|
||||
#: the t40 live-install blocker — ``media_player.py``'s
|
||||
#: ``extra_state_attributes``, a property today (the old line number rotted).
|
||||
ENTITY_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"activity",
|
||||
@@ -850,7 +878,77 @@ def scan_entity_contracts(paths=None) -> list[Finding]:
|
||||
_scan_entity_contracts(paths, report)
|
||||
return report.findings
|
||||
|
||||
# ------------------------------------------------------------ unreachable code
|
||||
|
||||
#: Statements that end the block they sit in: nothing AFTER one of these, in the
|
||||
#: SAME block, can ever run. ``return``/``raise`` end the block outright;
|
||||
#: ``continue``/``break`` end this pass through the enclosing loop, which is the
|
||||
#: same thing for the statements that follow them there.
|
||||
_TERMINAL_STATEMENTS = (ast.Return, ast.Raise, ast.Continue, ast.Break)
|
||||
|
||||
|
||||
def _statement_blocks(node: ast.AST):
|
||||
"""Every list-of-statements a node owns (bodies, ``orelse``, ``finalbody``)."""
|
||||
for _field, value in ast.iter_fields(node):
|
||||
if not isinstance(value, list) or not value:
|
||||
continue
|
||||
if all(isinstance(item, ast.stmt) for item in value):
|
||||
yield value
|
||||
|
||||
|
||||
def _scan_unreachable(tree: ast.AST, path: str, report: Report) -> None:
|
||||
"""t62 F2: flag statements that sit after a terminal one in the same block.
|
||||
|
||||
Per-block on purpose: a statement in a DIFFERENT block (an ``else``, an
|
||||
``except`` handler, a nested function) is not dead, and flagging those would
|
||||
make this pass noise. What it does see is the shape that shipped twice — a
|
||||
duplicated body left after ``return cached`` inside the same function
|
||||
(connection.py's label drain) — which is valid Python, invisible to every
|
||||
other gate here, and simply never executed.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
for block in _statement_blocks(node):
|
||||
report.statement_blocks += 1
|
||||
for index, statement in enumerate(block[:-1]):
|
||||
if not isinstance(statement, _TERMINAL_STATEMENTS):
|
||||
continue
|
||||
dead = block[index + 1]
|
||||
report.findings.append(
|
||||
Finding(
|
||||
path,
|
||||
dead.lineno,
|
||||
"unreachable-statement",
|
||||
f"{type(statement).__name__.lower()} at line "
|
||||
f"{statement.lineno} ends this block, so this statement "
|
||||
"can never run (duplicated or leftover code)",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
def _scan_unreachable_tree(paths, report: Report) -> None:
|
||||
"""Walk the tree and run the unreachable-statement pass over every file."""
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
continue # scan() reports bad-path for the same target
|
||||
for file in _iter_python_files(root):
|
||||
try:
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
_scan_unreachable(tree, file.name, report)
|
||||
|
||||
|
||||
def scan_unreachable(paths=None) -> list[Finding]:
|
||||
"""The unreachable-statement pass on its own (t62 F2) — the wrapper's proof."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_scan_unreachable_tree(paths, report)
|
||||
return report.findings
|
||||
# ----------------------------------------------------------------------- scan
|
||||
|
||||
|
||||
@@ -867,11 +965,13 @@ def scan(paths=None) -> Report:
|
||||
paths = [paths]
|
||||
|
||||
report = Report()
|
||||
# t40/t41: pure-AST checks, so they run even without homeassistant — that is
|
||||
# the whole point of a contract stubs cannot enforce (and a relative import
|
||||
# needs no library to be wrong).
|
||||
# t40/t41/t62: pure-AST checks, so they run even without homeassistant —
|
||||
# that is the whole point of a contract stubs cannot enforce (a relative
|
||||
# import needs no library to be wrong, and dead code needs no API to be
|
||||
# dead).
|
||||
_scan_entity_contracts(paths, report)
|
||||
_scan_package_imports(paths, report)
|
||||
_scan_unreachable_tree(paths, report)
|
||||
if not homeassistant_available():
|
||||
# Neither a false green nor a wall of phantom findings: with no package
|
||||
# to resolve against, every import would "fail", so the sweep reports
|
||||
|
||||
+784
-47
@@ -9,13 +9,14 @@ layer marks which tier served each command.
|
||||
import ast
|
||||
import asyncio
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
from entity_stublib import install, load_entity_modules
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
from nsh.const import TIVIMATE_PACKAGE
|
||||
from nsh.const import APP_LABEL_EXIT_MARKER, TIVIMATE_PACKAGE
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -117,7 +118,11 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player._entity_tasks = set()
|
||||
player._channel_task = None
|
||||
player.async_write_ha_state = lambda: None
|
||||
player._read = lambda eid: None
|
||||
# t61: FakeHass carries the real `states` shape, so the entity's own `_read`
|
||||
# is the one under test — an entity no test set still reads as None.
|
||||
# t61: `__init__` gives the entity an (empty) power-sensor map; the harness
|
||||
# mirrors it so the attribute payload can be read here too.
|
||||
player._power_sensors = {}
|
||||
|
||||
# t41: nothing may leave this integration, so an HA service call is now the
|
||||
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||
@@ -429,7 +434,7 @@ def _ladder_player(modules, foreground=None, audio=None):
|
||||
|
||||
|
||||
class TestForegroundLadderTier:
|
||||
"""P3-3a: the ladder branch between the media-session tier and ON."""
|
||||
"""P3-3a/t55: the ladder branch between the media-session tier and IDLE."""
|
||||
|
||||
def _player(self, modules, sessions=None, foreground=None, audio=None):
|
||||
player, _s, _i = make_player(modules)
|
||||
@@ -466,8 +471,9 @@ class TestForegroundLadderTier:
|
||||
audio=["ar.tvplayer.tv"],
|
||||
)
|
||||
|
||||
# TiviMate publishes no session (R2) — fg tier keeps the device ON
|
||||
assert player.state.value == "on"
|
||||
# TiviMate publishes no session (R2) — an open app, nothing playing:
|
||||
# the t55 ladder reports IDLE, never ON ("On" is reserved for no data)
|
||||
assert player.state.value == "idle"
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {
|
||||
"state": "idle",
|
||||
@@ -478,7 +484,7 @@ class TestForegroundLadderTier:
|
||||
async def test_no_session_app_without_hint_returns_none(self, modules):
|
||||
player, _m = self._player(modules, foreground="com.spocky.projengmenu")
|
||||
|
||||
assert player.state.value == "on" # launcher foreground → still ON
|
||||
assert player.state.value == "idle" # launcher open, nothing playing
|
||||
assert player._get_app_now_playing() is None
|
||||
|
||||
async def test_last_known_title_beats_audio_hint(self, modules):
|
||||
@@ -516,6 +522,314 @@ class TestForegroundLadderTier:
|
||||
assert np == {"state": "idle", "title": "101", "source": "app"}
|
||||
|
||||
|
||||
class _MpState:
|
||||
"""A HA State-shaped stand-in for the power entity / Jellyfin entity."""
|
||||
|
||||
def __init__(self, state, attributes=None):
|
||||
self.state = state
|
||||
self.attributes = attributes or {}
|
||||
|
||||
|
||||
def _state_player(
|
||||
modules,
|
||||
*,
|
||||
awake=None,
|
||||
sessions=None,
|
||||
foreground=None,
|
||||
protocol_on=None,
|
||||
power_entity_state=None,
|
||||
jellyfin_state=None,
|
||||
jellyfin_configured=False,
|
||||
):
|
||||
"""A player whose power/media sources are exactly what the test sets.
|
||||
|
||||
``None`` means "this source has not reported" — the distinction the t55
|
||||
ladder turns on, so every test spells out which sources spoke.
|
||||
``jellyfin_configured`` covers the entity being configured while ``_read``
|
||||
still answers None (the entity is gone).
|
||||
"""
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
if awake is not None:
|
||||
manager.store.set("awake", awake)
|
||||
if sessions is not None:
|
||||
manager.store.set("sessions", sessions)
|
||||
if foreground is not None:
|
||||
manager.store.set("foreground", foreground)
|
||||
if protocol_on is not None:
|
||||
manager.store.set("is_on", protocol_on)
|
||||
|
||||
entry = player._entry
|
||||
entry.options = dict(entry.options or {})
|
||||
if power_entity_state is not None:
|
||||
entry.options["power_entity"] = "switch.shield_plug"
|
||||
if jellyfin_state is not None or jellyfin_configured:
|
||||
entry.options["jellyfin_entity"] = "media_player.jellyfin"
|
||||
|
||||
def _read(entity_id):
|
||||
if entity_id == "switch.shield_plug" and power_entity_state is not None:
|
||||
return _MpState(power_entity_state)
|
||||
if entity_id == "media_player.jellyfin" and jellyfin_state is not None:
|
||||
return _MpState(jellyfin_state, {"media_title": "Movie"})
|
||||
return None
|
||||
|
||||
player._read = _read
|
||||
return player, manager
|
||||
|
||||
|
||||
class TestStateModelT55:
|
||||
"""t55: exactly {playing, paused, buffering, idle, off} + ON for no data.
|
||||
|
||||
The live defect: ``state`` returned ON for "powered on with an open app but
|
||||
nothing playing", which users read as "On overrides my playing states" and
|
||||
which sat ABOVE the media tiers in one branch. Every source that reports
|
||||
playing/paused/buffering must win, and ON must only describe the case where
|
||||
no source reported anything at all.
|
||||
"""
|
||||
|
||||
# ── the five states ──
|
||||
|
||||
async def test_no_data_at_all_is_the_only_on(self, modules):
|
||||
player, _m = _state_player(modules)
|
||||
|
||||
assert player.state.value == "on"
|
||||
|
||||
async def test_protocol_off_is_off(self, modules):
|
||||
player, _m = _state_player(modules, protocol_on=False)
|
||||
|
||||
assert player.state.value == "off"
|
||||
|
||||
async def test_power_entity_off_is_off_even_with_a_session(self, modules):
|
||||
"""Explicit OFF outranks a stale media source."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
power_entity_state="off",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
)
|
||||
|
||||
assert player.state.value == "off"
|
||||
|
||||
async def test_open_app_with_nothing_playing_is_idle(self, modules):
|
||||
"""The headline fix: ON no longer masks "on but nothing playing"."""
|
||||
player, _m = _state_player(
|
||||
modules, awake=True, foreground="ar.tvplayer.tv", protocol_on=True
|
||||
)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_powered_on_with_no_app_is_idle(self, modules):
|
||||
player, _m = _state_player(modules, protocol_on=True)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_power_entity_on_is_idle_not_on(self, modules):
|
||||
"""A plug reporting 'on' IS a source — ON is reserved for no data."""
|
||||
player, _m = _state_player(modules, power_entity_state="on")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_adb_standby_is_idle(self, modules):
|
||||
player, _m = _state_player(modules, awake=False)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
# ── each source outranks the idle/on fallbacks ──
|
||||
|
||||
async def test_jellyfin_playing_outranks_the_idle_fallback(self, modules):
|
||||
player, _m = _state_player(
|
||||
modules, awake=True, foreground="ar.tvplayer.tv", jellyfin_state="playing"
|
||||
)
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_jellyfin_playing_outranks_no_power_data(self, modules):
|
||||
"""No transport, no power entity — Jellyfin still knows better than ON."""
|
||||
player, _m = _state_player(modules, jellyfin_state="playing")
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_media_session_buffering_outranks_the_idle_fallback(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[
|
||||
MediaSessionInfo(package="com.netflix.ninja", buffering=True)
|
||||
],
|
||||
)
|
||||
|
||||
assert player.state.value == "buffering"
|
||||
|
||||
async def test_media_session_playing_outranks_the_idle_fallback(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
)
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_media_session_paused_outranks_the_idle_fallback(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
|
||||
)
|
||||
|
||||
assert player.state.value == "paused"
|
||||
|
||||
async def test_intent_playing_outranks_the_idle_fallback(self, modules):
|
||||
player, _m = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
|
||||
player._intent = SimpleNamespace(target_state="playing")
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_live_session_beats_a_stale_cached_one(self, modules):
|
||||
"""Session state 3/4 keep behaving as before: live truth, then cache."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, manager = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
|
||||
)
|
||||
info = MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Live")
|
||||
manager._last_known["com.netflix.ninja"] = (info, manager._clock())
|
||||
|
||||
assert player.state.value == "paused" # the live session, not the cache
|
||||
|
||||
# ── vocabulary guard: no other value can be produced ──
|
||||
|
||||
def test_the_vocabulary_is_exactly_the_five_states_plus_on(self, modules):
|
||||
from pathlib import Path
|
||||
|
||||
from nsh import media_player as mp
|
||||
|
||||
# Checked on the AST (the t37 idiom), not on prose: every state the
|
||||
# module can return is in the documented vocabulary, and the
|
||||
# media-tier values really are all used.
|
||||
tree = ast.parse(Path(mp.__file__).read_text(encoding="utf-8"))
|
||||
used = {
|
||||
node.attr
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "MediaPlayerState"
|
||||
}
|
||||
assert used <= {"PLAYING", "PAUSED", "BUFFERING", "IDLE", "OFF", "ON"}
|
||||
assert {"PLAYING", "PAUSED", "BUFFERING", "IDLE", "OFF"} <= used
|
||||
# The documented vocabulary itself, as the user reads it on the card.
|
||||
assert {s.lower() for s in used} == {
|
||||
"playing",
|
||||
"paused",
|
||||
"buffering",
|
||||
"idle",
|
||||
"off",
|
||||
"on",
|
||||
}
|
||||
|
||||
async def test_on_never_masks_a_reported_power_state(self, modules):
|
||||
"""Every power-reporting shape must be off or idle — never on."""
|
||||
shapes = [
|
||||
{"protocol_on": True},
|
||||
{"protocol_on": False},
|
||||
{"awake": True},
|
||||
{"awake": False},
|
||||
{"power_entity_state": "on"},
|
||||
{"power_entity_state": "off"},
|
||||
]
|
||||
for kwargs in shapes:
|
||||
player, _m = _state_player(modules, **kwargs)
|
||||
assert player.state.value != "on", kwargs
|
||||
|
||||
# ── BUFFERING: state, controls, now-playing ──
|
||||
|
||||
async def test_buffering_advertises_the_playback_controls(self, modules):
|
||||
from entity_stublib import MediaPlayerEntityFeature
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", buffering=True)],
|
||||
)
|
||||
|
||||
features = player.supported_features
|
||||
assert features & MediaPlayerEntityFeature.PLAY
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert features & MediaPlayerEntityFeature.STOP
|
||||
|
||||
async def test_no_session_still_means_base_features_only(self, modules):
|
||||
from entity_stublib import MediaPlayerEntityFeature
|
||||
|
||||
player, _m = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
|
||||
|
||||
assert not (player.supported_features & MediaPlayerEntityFeature.PLAY)
|
||||
|
||||
async def test_buffering_now_playing_is_named_buffering(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja", buffering=True, title="Movie"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
np = player._get_adb_now_playing()
|
||||
assert np["state"] == "buffering"
|
||||
assert np["title"] == "Movie"
|
||||
|
||||
async def test_buffering_survives_privacy_mode(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
awake=True,
|
||||
foreground="com.netflix.ninja",
|
||||
sessions=[
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja", buffering=True, title="Secret"
|
||||
)
|
||||
],
|
||||
)
|
||||
player._entry.options["adb_store_app_info"] = False
|
||||
|
||||
np = player._get_adb_now_playing()
|
||||
assert np["state"] == "buffering"
|
||||
assert np["title"] != "Secret"
|
||||
|
||||
async def test_last_known_buffering_reaches_the_foreground_tier(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, manager = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
|
||||
manager._clock = lambda: 0.0
|
||||
info = MediaSessionInfo(
|
||||
package="ar.tvplayer.tv", buffering=True, title="Sky News"
|
||||
)
|
||||
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
|
||||
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {"state": "buffering", "title": "Sky News", "source": "app"}
|
||||
|
||||
|
||||
class TestChannelLeakGuard:
|
||||
"""F1: the channel number never leaks to a non-channel foreground app."""
|
||||
|
||||
@@ -1145,9 +1459,10 @@ class TestFaithfulHaStubs:
|
||||
|
||||
|
||||
class TestBrowseMediaDirectory:
|
||||
"""P4-2c: the app directory root and its launch path."""
|
||||
"""t61: the HA media browser publishes NO media — apps are sources."""
|
||||
|
||||
async def test_root_lists_the_options_map(self, modules):
|
||||
async def test_the_browser_is_an_empty_root(self, modules):
|
||||
"""The user's report: apps must not appear in the media browser."""
|
||||
player, _s, _i = make_player(modules)
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
@@ -1156,53 +1471,56 @@ class TestBrowseMediaDirectory:
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert root.title == "Apps"
|
||||
assert root.can_play is False and root.can_expand is True
|
||||
ids = [(c.media_content_id, c.title) for c in root.children]
|
||||
assert ids == [
|
||||
("ar.tvplayer.tv", "TiviMate"),
|
||||
("com.netflix.ninja", "Netflix"),
|
||||
]
|
||||
assert all(c.media_content_type == "app" for c in root.children)
|
||||
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||
# media_content_id/-type at every level, the directory naming the class
|
||||
# of its children, and NO media_source `domain` (that parameter belongs
|
||||
# to media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids).
|
||||
assert root.children == [] # not one app row
|
||||
assert root.can_play is False
|
||||
assert root.can_expand is False # there is nothing to open
|
||||
assert root.title == "mdarcy" # the device, not a second app list
|
||||
# t37 D1: still a standard media_player.BrowseMedia root — explicit
|
||||
# class/type, NO media_source `domain` (that parameter belongs to
|
||||
# media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids and is not exportable from media_player at all).
|
||||
assert root.media_class == "directory"
|
||||
assert root.media_content_id == "apps"
|
||||
assert root.media_content_type == "apps"
|
||||
assert root.children_media_class == "app"
|
||||
assert root.media_content_id is None # no node to select
|
||||
assert root.children_media_class is None
|
||||
assert not hasattr(root, "domain")
|
||||
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||
# ...and it serialises into the payload the frontend consumes
|
||||
payload = root.as_dict()
|
||||
assert payload["media_content_id"] == "apps"
|
||||
assert payload["children_media_class"] == "app"
|
||||
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||
assert payload["children"][0]["media_content_type"] == "app"
|
||||
assert payload["children"][0]["can_play"] is True
|
||||
assert payload["children"] == []
|
||||
assert payload["can_expand"] is False
|
||||
|
||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||
player, _s, _i = make_player(modules) # no apps map configured
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
ids = [c.media_content_id for c in root.children]
|
||||
assert "ar.tvplayer.tv" in ids # TiviMate
|
||||
assert "com.github.damontecres.wholphin" in ids # Wholphin
|
||||
assert "com.netflix.ninja" in ids # Netflix
|
||||
assert "com.google.android.youtube.tv" in ids # YouTube
|
||||
|
||||
async def test_selection_launches_via_play_media_app(self, modules):
|
||||
async def test_apps_stay_reachable_through_the_sources(self, modules):
|
||||
"""What the browser no longer offers, source_list/select_source still do."""
|
||||
player, _s, _i = make_player(modules)
|
||||
protocol = player._manager._protocol
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
|
||||
root = await player.async_browse_media()
|
||||
selected = root.children[0]
|
||||
|
||||
await player.async_play_media("app", selected.media_content_id)
|
||||
assert root.children == []
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
|
||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||
protocol = player._manager._protocol
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
|
||||
|
||||
async def test_any_rebrowse_id_stays_a_valid_empty_root(self, modules):
|
||||
"""A frontend that re-browses an id cannot make this crash or lie."""
|
||||
player, _s, _i = make_player(modules)
|
||||
|
||||
for content_type, content_id in (
|
||||
(None, None),
|
||||
("apps", "apps"),
|
||||
("app", "ar.tvplayer.tv"),
|
||||
("channel", "12"),
|
||||
):
|
||||
root = await player.async_browse_media(content_type, content_id)
|
||||
|
||||
assert root.children == []
|
||||
assert root.media_content_id is None
|
||||
|
||||
|
||||
class DownAdbPoller(FakeAdbPoller):
|
||||
@@ -1259,7 +1577,7 @@ class TestCurrentAppSensorFriendlyName:
|
||||
def test_cached_manifest_label_is_the_second_tier(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock())
|
||||
manager.cache_app_label("com.netflix.ninja", "Netflix")
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
manager._on_current_app("com.netflix.ninja")
|
||||
@@ -1270,7 +1588,7 @@ class TestCurrentAppSensorFriendlyName:
|
||||
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
|
||||
manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}])
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
@@ -1299,6 +1617,299 @@ class TestCurrentAppSensorFriendlyName:
|
||||
assert sensor.native_value == player.app_name == "TiviMate"
|
||||
|
||||
|
||||
class _LabelPoller(FakeAdbPoller):
|
||||
"""FakeAdbPoller + the surface a real watchdog cycle needs.
|
||||
|
||||
``is_awake``/``poll_media_sessions``/``all_sessions``/``poll_running_apps``
|
||||
make ``_refresh`` a harmless cycle that still reaches the
|
||||
``_drain_app_label_requests`` call, so a test drives exactly the
|
||||
one-fetch-per-cycle behaviour the label tier depends on.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pm_dumps: dict[str, str] = {}
|
||||
self.dumps: list[str] = []
|
||||
#: t57: True = the transport never delivered output (a FAILED fetch)
|
||||
self.shell_failure = False
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
# FakeAdbPoller's property is always True; restated so this class reads
|
||||
# on its own and the watchdog cycle spends no retry on connect.
|
||||
return True
|
||||
|
||||
async def is_awake(self) -> bool:
|
||||
return True
|
||||
|
||||
async def poll_media_sessions(self, foreground_package=None, head_lines=200):
|
||||
return None
|
||||
|
||||
@property
|
||||
def all_sessions(self) -> list:
|
||||
return []
|
||||
|
||||
@property
|
||||
def last_dump(self) -> str:
|
||||
return ""
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
async def shell(self, cmd: str) -> str | None:
|
||||
"""Model BOTH real outcomes of a label dump (t57).
|
||||
|
||||
A package in ``pm_dumps`` answers with its dump. A package that is NOT
|
||||
there answers with the exit marker and nothing else — the shape a real
|
||||
transport produces for "the command ran and this package has no
|
||||
label". ``shell_failure`` turns every dump into the OTHER real shape:
|
||||
the transport never delivered output at all (``adb_client.shell``
|
||||
returns None when it is not connected or the command raised).
|
||||
"""
|
||||
self.dumps.append(cmd)
|
||||
import shlex
|
||||
|
||||
if self.shell_failure:
|
||||
return None
|
||||
for package, dump in self.pm_dumps.items():
|
||||
if shlex.quote(package) in cmd or package in cmd:
|
||||
return dump
|
||||
if APP_LABEL_EXIT_MARKER in cmd:
|
||||
return f"{APP_LABEL_EXIT_MARKER}0\n"
|
||||
return None
|
||||
|
||||
|
||||
class TestCurrentAppNameEndToEndT55:
|
||||
"""t55: the label fetch is driven off the CURRENT app, and a miss retries.
|
||||
|
||||
The live symptom was the Current App sensor (and app_name) showing a
|
||||
package id. These drive the real chain: sensor render → app_label cache
|
||||
miss → the fetch the manager enqueues → the next watchdog cycle drains it
|
||||
for the package that is actually on screen, NOT some earlier app.
|
||||
"""
|
||||
|
||||
def _wire(self, modules, current_app, dumps):
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
poller = _LabelPoller()
|
||||
poller.pm_dumps.update(dumps)
|
||||
manager._poller = poller
|
||||
player._entry.runtime_data = manager
|
||||
sensor = make_app_sensor(modules, manager, player._entry)
|
||||
|
||||
manager._on_current_app(current_app)
|
||||
return player, manager, sensor, poller
|
||||
|
||||
async def test_known_app_resolves_by_fetching_its_label(self, modules):
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
|
||||
)
|
||||
|
||||
# first render: cache miss → the sensor falls through to the package
|
||||
assert sensor.native_value == "com.netflix.ninja"
|
||||
assert manager.app_label_requests == {"com.netflix.ninja"}
|
||||
|
||||
# the watchdog drains the fetch for the CURRENT package
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert poller.dumps and "com.netflix.ninja" in poller.dumps[0]
|
||||
|
||||
# next render: the resolved device name, in the sensor AND the player
|
||||
assert sensor.native_value == "Netflix"
|
||||
assert sensor.extra_state_attributes["app_name"] == "Netflix"
|
||||
assert player.app_name == "Netflix"
|
||||
|
||||
async def test_mapped_app_never_needs_the_device_label(self, modules):
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
|
||||
)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate TV"}
|
||||
|
||||
assert sensor.native_value == "TiviMate TV"
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# the explicit map wins and no pm dump was issued for it
|
||||
assert sensor.native_value == "TiviMate TV"
|
||||
assert not [d for d in poller.dumps if "pm dump" in d]
|
||||
|
||||
async def test_unknown_app_falls_through_and_the_next_poll_retries(self, modules):
|
||||
"""The requirement, end to end: no permanent miss for an unresolvable app.
|
||||
|
||||
A failed fetch must not be cached as an answer: the next poll tries
|
||||
again. Only the fetch that RAN may stamp the negative.
|
||||
"""
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules, "com.example.unknown", {}
|
||||
)
|
||||
real_shell = poller.shell
|
||||
failures = {"n": 0}
|
||||
|
||||
async def _flaky(cmd):
|
||||
if "pm dump" in cmd:
|
||||
failures["n"] += 1
|
||||
raise RuntimeError("adb shell blew up")
|
||||
return await real_shell(cmd)
|
||||
|
||||
poller.shell = _flaky
|
||||
|
||||
# poll 1: fetch fails → still the package id, nothing cached
|
||||
assert sensor.native_value == "com.example.unknown"
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert sensor.native_value == "com.example.unknown"
|
||||
assert failures["n"] == 1
|
||||
assert "com.example.unknown" not in manager._app_labels
|
||||
|
||||
# poll 2: the render after the failure RE-ENQUEUES it — the retry is
|
||||
# driven by the next render, exactly like the live poll cycle does
|
||||
assert manager.app_label("com.example.unknown") is None
|
||||
assert manager.app_label_requests == {"com.example.unknown"}
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert failures["n"] == 2
|
||||
assert "com.example.unknown" not in manager._app_labels
|
||||
|
||||
# poll 3: the shell answers, the dump carries no label → a definitive
|
||||
# negative is cached and the sensor keeps falling through to the id
|
||||
poller.shell = real_shell
|
||||
assert manager.app_label("com.example.unknown") is None
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert sensor.native_value == "com.example.unknown"
|
||||
assert "com.example.unknown" in manager._app_labels
|
||||
|
||||
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
|
||||
async def test_a_dump_with_no_output_is_a_failure_not_an_answer(
|
||||
self, modules, blank
|
||||
):
|
||||
"""t59 F1 (HIGH): the live symptom, driven through the SENSOR.
|
||||
|
||||
``adb_client.shell`` swallows its error and returns None, and a real
|
||||
dump can also come back as an empty string. Neither is the device
|
||||
saying "this package has no label": reading either as an answer stamped
|
||||
a 24 h definitive negative after ONE transient failure, so the sensor
|
||||
stayed on the package id, exactly one dump was ever attempted, and a
|
||||
later GOOD dump could not recover the label. Both shapes are driven
|
||||
here — the pre-fix tree fails this at the second cycle.
|
||||
"""
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
|
||||
)
|
||||
|
||||
async def _blank(_cmd):
|
||||
poller.dumps.append(_cmd)
|
||||
return blank
|
||||
|
||||
# cycle 1: the transport delivered nothing → a FAILED fetch
|
||||
poller.shell = _blank
|
||||
assert sensor.native_value == "ar.tvplayer.tv"
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert "ar.tvplayer.tv" not in manager._app_labels # nothing stamped
|
||||
assert sensor.native_value == "ar.tvplayer.tv"
|
||||
|
||||
# the next render re-enqueues, and the retry is what recovers the label
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
dump_count = len([d for d in poller.dumps if "pm dump" in d])
|
||||
|
||||
real_shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
|
||||
poller.shell = real_shell
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# a later GOOD dump recovers it — the pre-fix cache made this impossible
|
||||
assert len([d for d in poller.dumps if "pm dump" in d]) == dump_count + 1
|
||||
assert sensor.native_value == "TiviMate"
|
||||
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
|
||||
assert player.app_name == "TiviMate"
|
||||
|
||||
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
|
||||
async def test_the_player_recovers_the_label_after_a_failed_fetch(
|
||||
self, modules, blank
|
||||
):
|
||||
"""Same defect, driven through ``media_player.app_name`` (t59 F1).
|
||||
|
||||
The sensor and the player read the same chain, but they are two
|
||||
different surfaces and the reviewer's reproduction observed BOTH
|
||||
stuck on the package id — so both are pinned.
|
||||
"""
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
|
||||
)
|
||||
|
||||
async def _blank(_cmd):
|
||||
poller.dumps.append(_cmd)
|
||||
return blank
|
||||
|
||||
poller.shell = _blank
|
||||
assert player.app_name == "com.netflix.ninja"
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert "com.netflix.ninja" not in manager._app_labels
|
||||
assert player.app_name == "com.netflix.ninja"
|
||||
|
||||
assert manager.app_label("com.netflix.ninja") is None # re-enqueued
|
||||
poller.shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert player.app_name == "Netflix"
|
||||
assert sensor.native_value == "Netflix"
|
||||
|
||||
async def test_a_genuinely_label_less_package_is_still_negatively_cached(
|
||||
self, modules
|
||||
):
|
||||
"""The other half: a dump that RAN and found no label stays definitive.
|
||||
|
||||
This is what keeps the t24 F1 rule closed — a label-less package costs
|
||||
zero further round trips. The marker in the output is what separates
|
||||
this from the failed fetch above.
|
||||
"""
|
||||
player, manager, sensor, poller = self._wire(modules, "com.oem.blank", {})
|
||||
|
||||
assert sensor.native_value == "com.oem.blank"
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert "com.oem.blank" in manager._app_labels # a definitive negative
|
||||
assert manager.app_label("com.oem.blank") is None
|
||||
assert manager.app_label_requests == set() # no re-enqueue, ever
|
||||
|
||||
dumps = [d for d in poller.dumps if "pm dump" in d]
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert [d for d in poller.dumps if "pm dump" in d] == dumps # zero more
|
||||
|
||||
async def test_the_fetch_targets_the_app_on_screen_not_the_previous_one(self, modules):
|
||||
player, manager, sensor, poller = self._wire(
|
||||
modules,
|
||||
"ar.tvplayer.tv",
|
||||
{
|
||||
"ar.tvplayer.tv": "application-label: 'TiviMate'",
|
||||
"com.google.android.youtube.tv": "application-label: 'YouTube'",
|
||||
},
|
||||
)
|
||||
|
||||
assert sensor.native_value == "ar.tvplayer.tv" # miss for the 1st app
|
||||
manager._on_current_app("com.google.android.youtube.tv")
|
||||
assert sensor.native_value == "com.google.android.youtube.tv"
|
||||
|
||||
# both packages are queued; the watchdog drains ONE per cycle
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# every drained fetch names a package, and the app that is CURRENT is
|
||||
# the one the sensor now shows the label of
|
||||
assert "ar.tvplayer.tv" in poller.dumps[0]
|
||||
assert "com.google.android.youtube.tv" in poller.dumps[1]
|
||||
assert sensor.native_value == "YouTube"
|
||||
|
||||
|
||||
class TestAvailabilityLadder:
|
||||
"""t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE.
|
||||
|
||||
@@ -1378,3 +1989,129 @@ class TestAvailabilityLadder:
|
||||
assert transport["entity_available"] is True
|
||||
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
|
||||
assert transport["protocol"]["active"] is True
|
||||
|
||||
|
||||
class TestReportingFactsT58:
|
||||
"""t58 UNIT 0 — the t59 F2 ruling: ON = "no power or playback fact reported".
|
||||
|
||||
The live report was "On overrides my playing states"; t55 fixed the loud half
|
||||
(a powered-on Shield with an app open). This is the quiet half: a source that
|
||||
ANSWERS "not playing" was still falling through to ON. Every case below is a
|
||||
source that answered, so the ladder must stop at IDLE.
|
||||
"""
|
||||
|
||||
# ── Jellyfin, in every state it can report ──
|
||||
|
||||
async def test_jellyfin_idle_is_idle_not_on(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="idle")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_off_is_idle_not_on(self, modules):
|
||||
"""`off` is a REPORT about the session, not the Shield's own power:
|
||||
only the configured power entity and the protocol push can say OFF."""
|
||||
player, _m = _state_player(modules, jellyfin_state="off")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_standby_is_idle_not_on(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="standby")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_jellyfin_unavailable_is_idle_not_on(self, modules):
|
||||
"""The one the ruling names explicitly: unavailable still answered."""
|
||||
player, _m = _state_player(modules, jellyfin_state="unavailable")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_a_configured_but_missing_jellyfin_entity_is_idle(self, modules):
|
||||
"""Configured IS the report: we are watching a source that exists.
|
||||
|
||||
`_read` returns None here (the entity is not in the state machine), and
|
||||
the old ladder fell straight through to ON — for an entry whose owner
|
||||
had explicitly configured a media source.
|
||||
"""
|
||||
player, _m = _state_player(modules, jellyfin_configured=True)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
# ── a live session with no playback flag, and a bare foreground app ──
|
||||
|
||||
async def test_a_session_with_no_playback_flag_is_idle_not_on(self, modules):
|
||||
"""The app publishes a session; that it is NOT playing is the report."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules, sessions=[MediaSessionInfo(package="com.netflix.ninja")]
|
||||
)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
async def test_a_bare_foreground_app_is_idle_not_on(self, modules):
|
||||
"""The protocol's current-app push answered — nothing else did."""
|
||||
player, _m = _state_player(modules, foreground="ar.tvplayer.tv")
|
||||
|
||||
assert player.state.value == "idle"
|
||||
|
||||
# ── the negative control: ON is still reachable, just narrowly ──
|
||||
|
||||
async def test_on_survives_when_truly_nothing_reported(self, modules):
|
||||
player, _m = _state_player(modules)
|
||||
|
||||
assert player.state.value == "on"
|
||||
|
||||
async def test_on_is_unreachable_with_any_source_configured(self, modules):
|
||||
"""The rule as a shape: each single source takes ON off the table."""
|
||||
for kwargs in (
|
||||
{"jellyfin_state": "idle"},
|
||||
{"jellyfin_state": "off"},
|
||||
{"jellyfin_state": "standby"},
|
||||
{"jellyfin_state": "unavailable"},
|
||||
{"jellyfin_configured": True},
|
||||
{"foreground": "ar.tvplayer.tv"},
|
||||
{"protocol_on": True},
|
||||
{"awake": True},
|
||||
{"power_entity_state": "on"},
|
||||
):
|
||||
player, _m = _state_player(modules, **kwargs)
|
||||
assert player.state.value != "on", kwargs
|
||||
|
||||
# ── a silent source must not MASK a live one (the ladder keeps going) ──
|
||||
|
||||
async def test_idle_jellyfin_does_not_mask_a_playing_session(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
jellyfin_state="idle",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
|
||||
assert player.state.value == "playing"
|
||||
|
||||
async def test_unavailable_jellyfin_does_not_mask_a_paused_session(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
jellyfin_state="unavailable",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
|
||||
assert player.state.value == "paused"
|
||||
|
||||
async def test_an_off_power_entity_still_outranks_a_playing_session(self, modules):
|
||||
"""The t55 rule is untouched by the F2 correction."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
power_entity_state="off",
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
)
|
||||
|
||||
assert player.state.value == "off"
|
||||
|
||||
@@ -69,6 +69,19 @@ def _install_import_stubs() -> None:
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape; the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
@@ -212,6 +225,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
||||
mocked_test_connect.assert_awaited_once_with(
|
||||
"shield.local", 5555, _KEY_PATH
|
||||
)
|
||||
# t56: the pairing hands over to the setup menu; Finish creates the entry
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,19 @@ def _install_stubs() -> None:
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape; the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
@@ -338,6 +351,10 @@ class TestProtocolPairing:
|
||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||
assert flow._unique_id == FAKE_MAC
|
||||
assert client.disconnected == 1
|
||||
# t56: a finished pairing hands over to the setup menu; its Finish row
|
||||
# creates the entry
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
||||
assert result["options"][CONF_HOST] == "shield.local"
|
||||
@@ -354,6 +371,8 @@ class TestProtocolPairing:
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
"""t56: the config flow against a REAL Home Assistant install.
|
||||
|
||||
The clearing semantics the live bug turned on ("a configured script cannot be
|
||||
removed") cannot be established from hand-written stubs: what matters is what
|
||||
Home Assistant itself does when a selector is cleared and how it serialises the
|
||||
form it hands the browser. So this file drives the REAL
|
||||
``homeassistant.data_entry_flow.FlowManager`` with the REAL integration, real
|
||||
``probatio`` validation and the real frontend serialiser
|
||||
(``probatio.to_field_list`` + ``cv.custom_serializer``) — the exact call the
|
||||
HTTP flow view makes.
|
||||
|
||||
Skips cleanly when homeassistant is not installed (every other test file runs
|
||||
against stubs). Run it with:
|
||||
|
||||
uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py -q
|
||||
|
||||
What this file CANNOT prove: there is no browser here, so nothing about how a
|
||||
form LOOKS. What it proves is the wire contract — the field list the frontend
|
||||
receives and the exact ``user_input`` a submission produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real FlowManager is the point of this file",
|
||||
)
|
||||
|
||||
probatio = pytest.importorskip("probatio")
|
||||
|
||||
import importlib.util # noqa: E402
|
||||
import sys # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
OptionsFlowManager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.data_entry_flow import FlowManager # noqa: E402
|
||||
from homeassistant.helpers import config_validation as cv # 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,
|
||||
)
|
||||
|
||||
# Load the integration by PATH under its real package name: the repo is not an
|
||||
# installed distribution, and the dotted import would need custom_components/ on
|
||||
# sys.path plus every one of its siblings present.
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
cf = importlib.import_module("custom_components.nvidia_shield.config_flow")
|
||||
|
||||
SCRIPTS = {"turn_on": "script.shield_wake", "volume_set": "script.shield_volume"}
|
||||
OPTIONS = {
|
||||
"app_name_map": {"ar.tvplayer.tv": "TiviMate"},
|
||||
"command_scripts": dict(SCRIPTS),
|
||||
"host": "10.0.0.9",
|
||||
"adb_enabled": True,
|
||||
}
|
||||
SCRIPT_COMMANDS = (
|
||||
"turn_on",
|
||||
"turn_off",
|
||||
"volume_up",
|
||||
"volume_down",
|
||||
"volume_set",
|
||||
"volume_mute",
|
||||
"select_source",
|
||||
)
|
||||
SCRIPT_FIELD_PREFIX = "script_"
|
||||
|
||||
|
||||
def field_list(schema):
|
||||
"""EXACTLY what the HTTP flow view serialises for the browser."""
|
||||
if schema is None:
|
||||
return []
|
||||
return probatio.to_field_list(schema, custom_serializer=cv.custom_serializer)
|
||||
|
||||
|
||||
def field_names(schema):
|
||||
return [field["name"] for field in field_list(schema)]
|
||||
|
||||
|
||||
def suggested(schema):
|
||||
return {
|
||||
field["name"]: (field.get("description") or {}).get("suggested_value")
|
||||
for field in field_list(schema)
|
||||
}
|
||||
|
||||
|
||||
#: Entities the probe refers to. The real EntitySelector validates an id
|
||||
#: against the entity registry, so these have to exist for a set (as opposed to
|
||||
#: a clear) to be a legal submission.
|
||||
ENTITIES = (
|
||||
"script.shield_wake",
|
||||
"script.shield_volume",
|
||||
"script.kept",
|
||||
"script.wake",
|
||||
"media_player.jellyfin",
|
||||
"switch.soundbar",
|
||||
"switch.plug",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with just enough machinery to submit real forms.
|
||||
|
||||
HomeAssistant binds the running loop in __init__, so this has to be an async
|
||||
fixture — which is also why the tests below are coroutines.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
# the real manager, exactly as core builds it — minus the reload SCHEDULE:
|
||||
# core would immediately set the domain up, which needs the integration
|
||||
# loader and the whole component bootstrap this probe deliberately skips.
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
# the real registry, populated with the entities the probe uses: a real
|
||||
# EntitySelector refuses an id the registry does not know
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
# EntityRegistry's own loader reaches deep into core (device migration,
|
||||
# restore, store), so the two containers it fills are set up directly —
|
||||
# same objects, no core bootstrap.
|
||||
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
|
||||
for entity_id in ENTITIES:
|
||||
domain, object_id = entity_id.split(".", 1)
|
||||
registry.async_get_or_create(
|
||||
domain, "test", entity_id, suggested_object_id=object_id
|
||||
)
|
||||
assert registry.async_get("script.kept") is not None
|
||||
return hass
|
||||
|
||||
|
||||
def make_entry(hass, options=None):
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain="nvidia_shield",
|
||||
title=f"Shield {len(hass.config_entries._entries) + 1}",
|
||||
data={},
|
||||
options=dict(options if options is not None else OPTIONS),
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
# the probe "saves" by writing back what create_entry returned, exactly as
|
||||
# core's async_update_entry would; ConfigEntry exposes options through a
|
||||
# read-only property, so the private store is written instead
|
||||
object.__setattr__(entry, "_options", dict(entry.options))
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
return entry
|
||||
|
||||
|
||||
|
||||
class SetupManager(FlowManager):
|
||||
"""A real FlowManager that builds the integration's real config flow.
|
||||
|
||||
Overrides only the handler construction, and does there what core does at
|
||||
config_entries.py:1959 — the flow's ``init_step`` is the SOURCE, which is
|
||||
why a real config flow starts at ``async_step_user`` and not at ``init``.
|
||||
"""
|
||||
|
||||
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||
flow = cf.NvidiaShieldConfigFlow()
|
||||
flow.init_step = (context or {}).get("source", SOURCE_USER)
|
||||
return flow
|
||||
|
||||
async def async_finish_flow(self, flow, result):
|
||||
return result
|
||||
|
||||
|
||||
def setup_manager(hass):
|
||||
return SetupManager(hass)
|
||||
|
||||
|
||||
async def open_setup(hass):
|
||||
"""Start a real user-initiated setup flow and return (manager, result)."""
|
||||
manager = setup_manager(hass)
|
||||
result = await manager.async_init(
|
||||
"nvidia_shield", context={"source": SOURCE_USER}
|
||||
)
|
||||
return manager, result
|
||||
|
||||
|
||||
#: The first setup form, as the real frontend submits it: EVERY rendered field.
|
||||
def first_form(**overrides):
|
||||
payload = {
|
||||
"name": "Shield",
|
||||
"jellyfin_entity": "",
|
||||
"receiver_entity": "",
|
||||
"receiver_input": "",
|
||||
"power_entity": "",
|
||||
"adb_enabled": False,
|
||||
"adb_store_app_info": True,
|
||||
"host": "10.0.0.9",
|
||||
"adb_port": 5555,
|
||||
"protocol_enabled": False,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class OptionsManager(OptionsFlowManager):
|
||||
"""OptionsFlowManager that builds the integration's real options flow.
|
||||
|
||||
Overrides ONLY the handler lookup: real HA resolves it through
|
||||
``async_get_options_flow`` from the registered integration, which needs a
|
||||
fully loaded core. Everything else — menu dispatch, schema validation,
|
||||
``next_step_id`` routing — is the real implementation.
|
||||
"""
|
||||
|
||||
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||
return cf.NvidiaShieldOptionsFlow()
|
||||
|
||||
|
||||
def options_manager(hass):
|
||||
return OptionsManager(hass)
|
||||
|
||||
|
||||
async def open_options(hass, entry):
|
||||
manager = options_manager(hass)
|
||||
result = await manager.async_init(
|
||||
entry.entry_id,
|
||||
context={"source": SOURCE_USER, "entry_id": entry.entry_id},
|
||||
)
|
||||
return manager, result
|
||||
|
||||
|
||||
def full_script_page(value=""):
|
||||
"""A real submission carries EVERY rendered field — this is that page."""
|
||||
return {f"{SCRIPT_FIELD_PREFIX}{c}": value for c in SCRIPT_COMMANDS}
|
||||
|
||||
|
||||
async def open_scripts(hass, entry):
|
||||
"""Open the Scripts sub-screen through the real menu."""
|
||||
manager, menu = await open_options(hass, entry)
|
||||
scripts = await manager.async_configure(menu["flow_id"], {"next_step_id": "scripts"})
|
||||
assert scripts["type"] == "form", scripts
|
||||
return manager, scripts
|
||||
|
||||
|
||||
class TestRealMenuFlow:
|
||||
"""The options page is a real menu with real sub-screens."""
|
||||
|
||||
async def test_the_page_is_a_menu_and_every_row_opens(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
assert menu["type"] == "menu"
|
||||
assert menu["step_id"] == "init"
|
||||
assert set(menu["menu_options"]) == {
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
}
|
||||
# HA renders the rows from the flow result, and validates the choice
|
||||
# against the menu's own schema
|
||||
assert field_names(menu["data_schema"]) == ["next_step_id"]
|
||||
|
||||
# each row is opened on its own flow, because a real flow keeps ONE
|
||||
# step at a time — reopening from a fresh menu is what the browser does
|
||||
for row in menu["menu_options"]:
|
||||
if row in ("finish", "discover"):
|
||||
# actions: `finish` commits and `discover` sweeps, neither renders
|
||||
continue
|
||||
_manager, fresh = await open_options(ha, entry)
|
||||
opened = await _manager.async_configure(
|
||||
fresh["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] == "form", row
|
||||
# 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
|
||||
|
||||
async def test_an_unknown_menu_row_is_rejected_by_the_real_schema(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
from homeassistant.data_entry_flow import InvalidData
|
||||
|
||||
# real HA validates the choice against the menu schema (probatio.In on
|
||||
# the offered ids) before the flow is even dispatched
|
||||
with pytest.raises(InvalidData):
|
||||
await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "not_a_step"}
|
||||
)
|
||||
|
||||
|
||||
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"})
|
||||
|
||||
suggested_values = suggested(apps["data_schema"])
|
||||
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
||||
assert isinstance(suggested_values["app_name_map"], str)
|
||||
|
||||
# and nothing in the whole field list carries a dict for a text field
|
||||
for field in field_list(apps["data_schema"]):
|
||||
value = (field.get("description") or {}).get("suggested_value")
|
||||
if field.get("type") == "string":
|
||||
assert not isinstance(value, (dict, list)), field["name"]
|
||||
|
||||
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"})
|
||||
|
||||
fields = field_list(apps["data_schema"])
|
||||
assert [f["name"] for f in fields] == ["app_name_map"]
|
||||
assert (fields[0].get("description") or {}).get("suggested_value") in (None, "")
|
||||
|
||||
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"})
|
||||
|
||||
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
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
assert created["data"]["app_icon_map"] == {
|
||||
"com.plex.hdx": "http://icons.local/plex.png"
|
||||
}
|
||||
|
||||
|
||||
class TestRealScriptClearing:
|
||||
"""The live bug: what the REAL flow stores when a selector is cleared."""
|
||||
|
||||
async def test_set_then_cleared_persists_through_save_and_reopen(self, ha):
|
||||
"""t56 unit 0 / t57 F4: SET, CLEAR, SAVE, REOPEN — the live symptom.
|
||||
|
||||
The whole page goes through the real flow manager: first with a script
|
||||
SELECTED (an entity id, which the seeded registry resolves), then with
|
||||
that one selector cleared while the other keeps its value. Both halves
|
||||
are driven here — the earlier version of this file claimed entity ids
|
||||
could not be driven, which was simply false (the receiver page in this
|
||||
file has always submitted ``switch.soundbar``).
|
||||
"""
|
||||
entry = make_entry(ha)
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
# 1. SET: two commands selected through the real flow, from real ids
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "script.kept"
|
||||
page["script_volume_set"] = SCRIPTS["volume_set"]
|
||||
after_set = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert after_set["type"] == "menu", after_set
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_set["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["command_scripts"] == {
|
||||
"turn_on": "script.kept",
|
||||
"volume_set": SCRIPTS["volume_set"],
|
||||
}
|
||||
|
||||
# 2. CLEAR: the same page with ONE selector emptied (a cleared selector
|
||||
# arrives as ''), the other left set
|
||||
saved = dict(entry.options)
|
||||
saved.update(created["data"])
|
||||
object.__setattr__(entry, "_options", saved)
|
||||
manager2, scripts2 = await open_scripts(ha, entry)
|
||||
assert suggested(scripts2["data_schema"])["script_turn_on"] == "script.kept"
|
||||
|
||||
cleared = full_script_page("")
|
||||
cleared["script_turn_on"] = ""
|
||||
cleared["script_volume_set"] = SCRIPTS["volume_set"]
|
||||
after_clear = await manager2.async_configure(scripts2["flow_id"], cleared)
|
||||
assert after_clear["type"] == "menu", after_clear
|
||||
created2 = await manager2.async_configure(
|
||||
after_clear["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created2["data"]["command_scripts"] == {
|
||||
"volume_set": SCRIPTS["volume_set"],
|
||||
}
|
||||
|
||||
# 3. PERSIST: re-open over the SAVED options — the cleared command must
|
||||
# not come back, and no flat script_* key was ever stored
|
||||
saved2 = dict(entry.options)
|
||||
saved2.update(created2["data"])
|
||||
object.__setattr__(entry, "_options", saved2)
|
||||
assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)]
|
||||
manager3, scripts3 = await open_scripts(ha, entry)
|
||||
values = suggested(scripts3["data_schema"])
|
||||
assert values["script_volume_set"] == SCRIPTS["volume_set"]
|
||||
assert values.get("script_turn_on") in (None, "")
|
||||
|
||||
@pytest.mark.parametrize("shape", ["empty_string", "null"])
|
||||
async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape):
|
||||
"""Real HA rejects '' / None for a plain EntitySelector; ours tolerates it."""
|
||||
entry = make_entry(ha)
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "" if shape == "empty_string" else None
|
||||
saved = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert saved["type"] == "menu", saved
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["command_scripts"] == {}
|
||||
|
||||
async def test_a_never_set_field_stays_out_of_the_dict(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9", "adb_enabled": True})
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
# a real submission carries every rendered field, all empty here
|
||||
saved = await manager.async_configure(scripts["flow_id"], full_script_page(""))
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["command_scripts"] == {}
|
||||
|
||||
|
||||
class TestRealSelectorValidation:
|
||||
"""The selector distinction, pinned on the selector classes themselves.
|
||||
|
||||
``TestRealScriptClearing`` drives a cleared page end to end; this class pins
|
||||
the underlying difference in isolation, so a regression says WHICH rule broke:
|
||||
the built-in EntitySelector rejects a cleared field, ours accepts it and
|
||||
still validates a real id.
|
||||
"""
|
||||
|
||||
def test_real_entity_selector_rejects_a_cleared_field(self):
|
||||
from homeassistant.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
)
|
||||
|
||||
strict = EntitySelector(EntitySelectorConfig(domain="script"))
|
||||
for cleared in ("", None):
|
||||
with pytest.raises(probatio.Invalid):
|
||||
strict(cleared)
|
||||
|
||||
def test_the_clearing_selector_accepts_cleared_and_real_values(self):
|
||||
from custom_components.nvidia_shield.config_flow import _build_selectors
|
||||
|
||||
selector = _build_selectors()["script"]
|
||||
assert selector("") == ""
|
||||
assert selector(None) == ""
|
||||
assert selector(" ") == ""
|
||||
assert selector("script.whatever") == "script.whatever"
|
||||
|
||||
class TestRealNoLostEdits:
|
||||
"""Edits made on one sub-screen survive a submission on another."""
|
||||
|
||||
async def test_connection_then_scripts_then_apps_all_survive(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
connection = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "connection"}
|
||||
)
|
||||
after_connection = await manager.async_configure(
|
||||
connection["flow_id"],
|
||||
{
|
||||
"host": "10.0.0.44",
|
||||
"adb_port": 5555,
|
||||
"adb_enabled": True,
|
||||
"protocol_enabled": False,
|
||||
"jellyfin_entity": "media_player.jellyfin",
|
||||
},
|
||||
)
|
||||
assert after_connection["type"] == "menu"
|
||||
|
||||
scripts = await manager.async_configure(
|
||||
after_connection["flow_id"], {"next_step_id": "scripts"}
|
||||
)
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "script.wake"
|
||||
after_scripts = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert after_scripts["type"] == "menu"
|
||||
|
||||
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 after_apps["type"] == "menu"
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
data = created["data"]
|
||||
# every earlier sub-screen's edit is still there
|
||||
assert data["host"] == "10.0.0.44"
|
||||
assert data["jellyfin_entity"] == "media_player.jellyfin"
|
||||
assert data["command_scripts"] == {"turn_on": "script.wake"}
|
||||
assert data["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
|
||||
|
||||
class TestRealReceiverSelector:
|
||||
"""The Receiver field accepts a switch, not only a media_player."""
|
||||
|
||||
async def test_the_selector_offers_switch_domains(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
receiver = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
|
||||
fields = {f["name"]: f for f in field_list(receiver["data_schema"])}
|
||||
selector = fields["receiver_entity"]["selector"]["entity"]
|
||||
assert "switch" in selector["domain"]
|
||||
assert "media_player" in selector["domain"]
|
||||
|
||||
async def test_a_switch_is_accepted_and_stored(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
receiver = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
|
||||
saved = await manager.async_configure(
|
||||
receiver["flow_id"],
|
||||
{"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 2"},
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["receiver_entity"] == "switch.soundbar"
|
||||
assert created["data"]["receiver_input"] == "HDMI 2"
|
||||
|
||||
|
||||
class TestRealSetupMenu:
|
||||
"""t57 UNIT 0a / F2: the SETUP flow under a REAL FlowManager.
|
||||
|
||||
The stub suite drove `async_step_menu` directly, which is exactly how a
|
||||
broken menu stays green: it never asked Home Assistant to dispatch a row.
|
||||
These tests do, for every row, and they are the red proof for the TypeError
|
||||
the first form used to hit on the way to the menu.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _no_connect(result=True):
|
||||
async def _connect(*args, **kwargs):
|
||||
return result
|
||||
|
||||
return staticmethod(_connect)
|
||||
|
||||
async def test_the_first_form_leads_into_pairing_then_the_menu(self, ha, monkeypatch):
|
||||
"""The whole ordinary path: first form → ADB pairing → the menu."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "user"
|
||||
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
assert pairing["type"] == "form"
|
||||
assert pairing["step_id"] == "adb_pair"
|
||||
|
||||
result = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
assert result["type"] == "menu", result
|
||||
# ADB is paired, the protocol is off: only the two sub-screens + finish
|
||||
assert list(result["menu_options"]) == ["receiver", "connection", "finish"]
|
||||
|
||||
async def test_the_adb_skip_reaches_the_menu(self, ha, monkeypatch):
|
||||
"""The path that used to die: skip ADB pairing, then render the menu."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
assert pairing["step_id"] == "adb_pair"
|
||||
|
||||
result = await manager.async_configure(
|
||||
pairing["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu", result
|
||||
assert "adb_pair" in result["menu_options"] # not paired: still offered
|
||||
|
||||
async def test_adb_pairing_success_stops_offering_that_row(self, ha, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
paired = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
assert paired["type"] == "menu"
|
||||
assert "adb_pair" not in paired["menu_options"] # F2: the guard
|
||||
|
||||
async def test_every_setup_menu_row_dispatches_and_renders(self, ha, monkeypatch):
|
||||
"""Every row HA offers must have a handler AND render a form whose
|
||||
step_id is that same row, or the page dead-ends on submit."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
# the protocol row builds the androidtvremote2 client; this test is about
|
||||
# DISPATCH, and the pairing internals have their own stub coverage. The
|
||||
# UNREACHABLE branch is a real code path needing no library, so the row
|
||||
# renders its real form.
|
||||
async def _unreachable(self):
|
||||
return None, "protocol_unreachable"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||
)
|
||||
|
||||
manager, form = await open_setup(ha)
|
||||
menu_result = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
first_form(adb_enabled=True, protocol_enabled=True),
|
||||
)
|
||||
assert menu_result["type"] == "form" # the ADB pairing form comes first
|
||||
|
||||
# hand the ADB form a skip so the MENU renders with every row
|
||||
menu_result = await manager.async_configure(
|
||||
menu_result["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
assert menu_result["type"] == "menu", menu_result
|
||||
rows = list(menu_result["menu_options"])
|
||||
assert rows == ["receiver", "connection", "adb_pair", "protocol_pair", "finish"]
|
||||
|
||||
for row in rows:
|
||||
# each row on its own flow (a flow is at one step at a time) AND on
|
||||
# the manager that owns it: a flow lives in its manager's progress
|
||||
row_manager, fresh = await open_setup(ha)
|
||||
pairing = await row_manager.async_configure(
|
||||
fresh["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||
)
|
||||
menu = await row_manager.async_configure(
|
||||
pairing["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
opened = await row_manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] in ("form", "menu", "create_entry"), (row, opened)
|
||||
if opened["type"] == "form":
|
||||
assert opened["step_id"] == row, (row, opened["step_id"])
|
||||
|
||||
async def test_a_setup_sub_screen_submission_is_accepted(self, ha, monkeypatch):
|
||||
"""A rendered sub-screen must be re-submittable (row id == step_id)."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
assert menu["type"] == "menu"
|
||||
|
||||
opened = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
assert opened["type"] == "form"
|
||||
assert opened["step_id"] == "receiver"
|
||||
|
||||
back = await manager.async_configure(
|
||||
opened["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||
)
|
||||
assert back["type"] == "menu", back
|
||||
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||
|
||||
async def test_the_full_menu_walk_merges_and_creates_one_entry(self, ha, monkeypatch):
|
||||
"""connection + receiver + finish → ONE entry carrying both edits."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
connection = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "connection"}
|
||||
)
|
||||
assert connection["step_id"] == "connection"
|
||||
menu2 = await manager.async_configure(
|
||||
connection["flow_id"], {"host": "10.0.0.55", "adb_port": 5555,
|
||||
"adb_enabled": True, "protocol_enabled": False}
|
||||
)
|
||||
assert menu2["type"] == "menu"
|
||||
|
||||
receiver = await manager.async_configure(
|
||||
menu2["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
menu3 = await manager.async_configure(
|
||||
receiver["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||
)
|
||||
|
||||
created = await manager.async_configure(
|
||||
menu3["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["options"]["host"] == "10.0.0.55"
|
||||
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adb_enabled", "protocol_enabled", "connect", "pairs_protocol"),
|
||||
[
|
||||
(True, False, False, False), # ADB skipped
|
||||
(True, False, True, False), # ADB paired, protocol off
|
||||
(True, True, False, False), # ADB skipped, protocol skipped
|
||||
(True, True, True, False), # ADB paired, protocol skipped
|
||||
(False, True, False, False), # protocol only
|
||||
],
|
||||
ids=["adb-skip", "adb-paired", "adb-skip+protocol-skip",
|
||||
"pair-adb", "protocol-only"],
|
||||
)
|
||||
async def test_every_ordinary_path_reaches_create_entry(
|
||||
self, ha, monkeypatch, adb_enabled, protocol_enabled, connect, pairs_protocol
|
||||
):
|
||||
"""t57 UNIT 0a: the path map.
|
||||
|
||||
Under the pre-fix tree FOUR of these five died with
|
||||
`TypeError: async_show_menu() got an unexpected keyword argument
|
||||
'errors'` the moment the ADB pairing form was submitted or skipped —
|
||||
so a new entry could not be created on any path that went near the
|
||||
menu, and the sub-screens were unreachable. Each path is driven here to
|
||||
`create_entry`.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(connect)
|
||||
)
|
||||
|
||||
async def _unreachable(self):
|
||||
return None, "protocol_unreachable"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
result = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
first_form(adb_enabled=adb_enabled, protocol_enabled=protocol_enabled),
|
||||
)
|
||||
assert result["type"] == "form", result
|
||||
|
||||
for _ in range(12):
|
||||
if result["type"] == "create_entry":
|
||||
break
|
||||
step = result.get("step_id")
|
||||
if step == "adb_pair":
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
elif step == "protocol_pair":
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"],
|
||||
{"pairing_code": "1234", "host": "10.0.0.9", "skip_pairing": True},
|
||||
)
|
||||
elif result["type"] == "menu":
|
||||
# the menu must always offer a way out
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
else: # pragma: no cover - the assertion below reports it
|
||||
break
|
||||
|
||||
assert result["type"] == "create_entry", result
|
||||
|
||||
async def test_the_menu_description_reports_what_is_unpaired(self, ha, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"skip_pairing": True})
|
||||
|
||||
assert menu["type"] == "menu"
|
||||
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||
assert "ADB" in note and "remote protocol" in note
|
||||
+735
-54
File diff suppressed because it is too large.
Load diff
+104
-6
@@ -18,7 +18,7 @@ from nsh.connection import (
|
||||
ShieldConnectionManager,
|
||||
StateStore,
|
||||
)
|
||||
from nsh.const import ADB_POLL_INTERVAL
|
||||
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
|
||||
|
||||
|
||||
def test_connection_module_depends_on_no_homeassistant():
|
||||
@@ -177,11 +177,18 @@ class TestAppLabelCache:
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
# still None (next tier), no crash — falls through cleanly
|
||||
assert manager.app_label("com.unknown.app") is not None or True
|
||||
# the dump RAN and carried no label: definitive negative, and the
|
||||
# caller's next tier (the raw package) takes over — still None here,
|
||||
# never a crash or a fabricated label
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
|
||||
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
|
||||
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
|
||||
async def test_label_less_package_is_a_definitive_negative(self):
|
||||
"""t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
|
||||
|
||||
The old code additionally stamped this answer on FAILURE paths; t55
|
||||
separates the two (see the retry tests below), but the negative cache
|
||||
for a dump that really ran stays — the round-trip tax must not return.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||
|
||||
@@ -201,6 +208,94 @@ class TestAppLabelCache:
|
||||
assert len(manager._poller.shell_calls) == first_dump_count
|
||||
assert manager.app_label("com.unknown.app") is None # falls through
|
||||
|
||||
async def test_failed_fetch_is_not_cached_and_retries(self):
|
||||
"""t55 root cause: a fetch that could NOT run must not become a miss.
|
||||
|
||||
The live defect was a metadata fetch failing and the failure being
|
||||
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
|
||||
which left app_name showing the raw package id with no way back. Here
|
||||
the failed fetch stamps nothing, the next render re-enqueues, and the
|
||||
retry resolves the real label.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
real_shell = manager._poller.shell
|
||||
calls: list[str] = []
|
||||
|
||||
async def _flaky(cmd):
|
||||
calls.append(cmd)
|
||||
if "pm dump" in cmd:
|
||||
raise RuntimeError("adb shell blew up")
|
||||
return await real_shell(cmd)
|
||||
|
||||
manager._poller.shell = _flaky
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# the fetch ran and failed → NOTHING is stamped as an answer
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert [c for c in calls if "pm dump" in c]
|
||||
|
||||
# the next render enqueues again (the miss is retried, not silenced)
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
|
||||
# the retry reaches the device and resolves the label
|
||||
manager._poller.shell = real_shell
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||
|
||||
async def test_shell_failure_is_not_cached_and_retries(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
|
||||
|
||||
async def _boom(_cmd):
|
||||
raise RuntimeError("adb shell blew up")
|
||||
|
||||
manager._poller.shell = _boom
|
||||
manager.app_label("ar.tvplayer.tv")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == set()
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
|
||||
|
||||
async def test_in_flight_fetch_is_not_enqueued_twice(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
assert manager.app_label_inflight == set()
|
||||
|
||||
async def test_one_fetch_per_cycle_even_under_a_burst(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
|
||||
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
|
||||
|
||||
manager.app_label("com.a")
|
||||
manager.app_label("com.b")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
|
||||
assert len(dumps) == 2 # one per cycle, both eventually resolved
|
||||
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
|
||||
"A",
|
||||
"B",
|
||||
]
|
||||
|
||||
async def test_pm_dump_command_is_shell_quoted(self):
|
||||
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
||||
import shlex
|
||||
@@ -215,8 +310,11 @@ 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
|
||||
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 | head -300; "
|
||||
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,22 @@ PRE_FIX_SOURCES = {
|
||||
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||
" self._async_handle_discovery_without_unique_id()\n"
|
||||
),
|
||||
# t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes
|
||||
# no `errors` in HA 2026.10.1, and the integration passed one — so every
|
||||
# ordinary setup path raised TypeError at the moment a user submitted the
|
||||
# first form, which is invisible to a stub suite whose stubs accept it.
|
||||
"t57_show_menu_errors.py": (
|
||||
"from homeassistant import config_entries\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||
" async def async_step_user(self, user_input=None):\n"
|
||||
" return self.async_show_menu(\n"
|
||||
" step_id='menu',\n"
|
||||
" menu_options=['finish'],\n"
|
||||
" errors={\'base\': \'nope\'},\n"
|
||||
" )\n"
|
||||
),
|
||||
"t35_options_updates.py": (
|
||||
"from homeassistant import config_entries\n"
|
||||
"\n"
|
||||
@@ -106,6 +122,7 @@ def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
|
||||
("d2_util_diagnostic.py", "broken-import"),
|
||||
("d3_unawaited_discovery.py", "missing-await"),
|
||||
("t35_options_updates.py", "unknown-kwarg"),
|
||||
("t57_show_menu_errors.py", "unknown-kwarg"),
|
||||
}, "\n" + str(report)
|
||||
|
||||
|
||||
@@ -290,6 +307,12 @@ _REAL_HA_MATRIX = (
|
||||
{"jellyfin_entity": "media_player.jellyfin"},
|
||||
"sensor.shield_now_playing",
|
||||
),
|
||||
(
|
||||
"sensor",
|
||||
"NvidiaShieldAppsSensor",
|
||||
{"adb_enabled": True},
|
||||
"sensor.shield_installed_apps",
|
||||
),
|
||||
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||
)
|
||||
@@ -358,3 +381,102 @@ def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
|
||||
f"entity classes never exercised against real HA: {sorted(uncovered)} — "
|
||||
f"add them to _REAL_HA_MATRIX"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------- t62: dead code and the floor
|
||||
#
|
||||
# Both defects pinned below shipped while every other gate was green:
|
||||
# connection.py carried a duplicated copy of the label-drain body AFTER its
|
||||
# `return cached` (valid Python, dead, invisible to this sweep), and the
|
||||
# call-argument floor sat at 20 while the counts had moved to ~46, so deleting a
|
||||
# flow-display API owner from HA_CALL_OWNERS did not trip it.
|
||||
|
||||
#: t62 F2: the shape that shipped — a bare docstring plus the whole drain left
|
||||
#: after `return cached`, inside the same method. Line 6 is the dead statement.
|
||||
UNREACHABLE_PRE_FIX = (
|
||||
"class Sweep:\n"
|
||||
" def labels(self, requests):\n"
|
||||
" cached = {}\n"
|
||||
" return cached\n"
|
||||
"\n"
|
||||
' """Start at most one label fetch per watchdog cycle."""\n'
|
||||
" if not requests:\n"
|
||||
" return\n"
|
||||
" requests.discard(min(requests))\n"
|
||||
)
|
||||
|
||||
|
||||
def test_the_sweep_flags_the_pre_fix_unreachable_body(tmp_path):
|
||||
"""t62 F2 red proof: the duplicated drain body after `return cached` is dead."""
|
||||
(tmp_path / "dead_sweep.py").write_text(UNREACHABLE_PRE_FIX, encoding="utf-8")
|
||||
|
||||
findings = ha_api_drift.scan_unreachable(tmp_path)
|
||||
|
||||
assert [(finding.path, finding.kind) for finding in findings] == [
|
||||
("dead_sweep.py", "unreachable-statement")
|
||||
], findings
|
||||
assert findings[0].lineno == 6 # the dead docstring, not the return
|
||||
|
||||
|
||||
def test_every_terminal_statement_kind_is_flagged(tmp_path):
|
||||
"""`return`, `raise`, `continue` and `break` all end a block (t62 F2)."""
|
||||
(tmp_path / "terminals.py").write_text(
|
||||
"def a():\n"
|
||||
" return 1\n"
|
||||
" x = 1\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def b():\n"
|
||||
" raise SystemExit(1)\n"
|
||||
" y = 2\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def c(items):\n"
|
||||
" for item in items:\n"
|
||||
" continue\n"
|
||||
" z = 3\n"
|
||||
" while True:\n"
|
||||
" break\n"
|
||||
" w = 4\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
findings = ha_api_drift.scan_unreachable(tmp_path)
|
||||
|
||||
assert [finding.kind for finding in findings] == ["unreachable-statement"] * 4, findings
|
||||
|
||||
|
||||
def test_the_production_tree_has_no_unreachable_statements():
|
||||
"""The gate itself: dead code is a finding, so it cannot ship silently."""
|
||||
assert ha_api_drift.scan_unreachable() == []
|
||||
|
||||
|
||||
def test_the_unreachable_pass_walks_real_statement_blocks():
|
||||
"""The floor proves "no findings" is not a pass that stopped parsing."""
|
||||
report = ha_api_drift.scan()
|
||||
|
||||
assert report.statement_blocks >= ha_api_drift.FLOORS["statement_blocks"]
|
||||
|
||||
|
||||
def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch):
|
||||
"""t62: the floor must notice an API OWNER going away, not just a bad walk."""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
intact = ha_api_drift.scan()
|
||||
assert intact.floor_violations() == []
|
||||
|
||||
reduced = dict(ha_api_drift.HA_CALL_OWNERS)
|
||||
del reduced["async_show_menu"] # the t57 UNIT 0a owner: 6 checked arguments
|
||||
monkeypatch.setattr(ha_api_drift, "HA_CALL_OWNERS", reduced)
|
||||
|
||||
fewer = ha_api_drift.scan()
|
||||
|
||||
assert fewer.call_args < intact.call_args
|
||||
assert fewer.call_args < ha_api_drift.FLOORS["call_args"], (
|
||||
f"removing async_show_menu left {fewer.call_args} checked arguments, "
|
||||
f"still at or above the floor of {ha_api_drift.FLOORS['call_args']} — "
|
||||
"the flow-display owners are not load-bearing"
|
||||
)
|
||||
assert any("call_args" in line for line in fewer.floor_violations()), (
|
||||
fewer.floor_violations()
|
||||
)
|
||||
@@ -0,0 +1,669 @@
|
||||
"""t57: installed-app discovery — parsers, the bounded sweep, and the surfaces.
|
||||
|
||||
No real Shield was available, so the command OUTPUT here is the part that is
|
||||
fixture-based, not observed: the shapes are the documented Android ones
|
||||
(``pm list packages`` prints ``package:<id>``; ``resolve-activity --brief``
|
||||
prints the verbose intent then ``package/activity``; ``pm dump`` prints
|
||||
``application-label: '...'``). The parsers are deliberately tolerant because
|
||||
that is the property that matters when a real device's output differs in
|
||||
whitespace or ordering, and every tolerance is pinned below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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 load_entity_modules # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + loaded entity modules (t7 discipline).
|
||||
|
||||
Without the teardown the stubs this file installs leak into whichever test
|
||||
file runs next — tests/test_harness_scoping.py exists because that mistake
|
||||
is invisible until an unrelated file fails.
|
||||
"""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
yield load_entity_modules()
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
from nsh.connection import ShieldConnectionManager # noqa: E402
|
||||
from nsh.const import ( # noqa: E402
|
||||
APP_ICON_FALLBACKS,
|
||||
APP_LIST_TTL,
|
||||
CONF_APP_NAME_MAP,
|
||||
)
|
||||
from nsh.ha_adb_common import ( # noqa: E402
|
||||
icon_for_package,
|
||||
installed_packages,
|
||||
is_launchable,
|
||||
parse_active_launcher,
|
||||
parse_package_list,
|
||||
)
|
||||
|
||||
# ── 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).
|
||||
USER_PACKAGES = (
|
||||
"package:ar.tvplayer.tv\n"
|
||||
"package:com.github.damontecres.wholphin\n"
|
||||
"package:com.netflix.ninja\n"
|
||||
"package:com.google.android.youtube.tv\n"
|
||||
"package:org.xbmc.kodi\n"
|
||||
"package:com.plexapp.android\n"
|
||||
"package:com.spotify.tv.android\n"
|
||||
"package:com.example.handmade\n"
|
||||
)
|
||||
|
||||
#: `cmd package resolve-activity --brief -c android.intent.category.HOME` —
|
||||
#: the verbose header then the component, exactly as Android prints it.
|
||||
LAUNCHER_RESOLVED = (
|
||||
"priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false\n"
|
||||
"com.google.android.tvlauncher/com.google.android.apps.tv.launcherx.MainActivity\n"
|
||||
)
|
||||
|
||||
#: `pm dump` for an app whose manifest and locale variants both exist. The
|
||||
#: parser takes the first default-locale label and ignores the localized ones.
|
||||
DUMP_TIVIMATE = (
|
||||
"Package [ar.tvplayer.tv] (7f1a2b3c):\n"
|
||||
" application-label:'TiviMate'\n"
|
||||
" application-label-de:'TiviMate DE'\n"
|
||||
" application-label-fr:'TiviMate FR'\n"
|
||||
" versionName=4.7.0\n"
|
||||
)
|
||||
|
||||
#: A dump that RAN and carries no label at all — several system/OEM packages
|
||||
#: look like this. It is a definitive negative, not a failed fetch.
|
||||
DUMP_LABEL_LESS = (
|
||||
"Package [com.example.handmade] (aa11bb22):\n"
|
||||
" versionName=1.0\n"
|
||||
" codePath=/data/app/com.example.handmade\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):
|
||||
self.connected = True
|
||||
self.connected_override = True
|
||||
self.packages = packages
|
||||
self.launcher = launcher
|
||||
self.dumps = dict(dumps or {})
|
||||
self.failure = failure
|
||||
self.shell_calls: list[str] = []
|
||||
|
||||
@property
|
||||
def is_connected(self):
|
||||
return self.connected
|
||||
|
||||
async def shell(self, cmd: str):
|
||||
self.shell_calls.append(cmd)
|
||||
if self.failure:
|
||||
return None
|
||||
if "pm list packages" in cmd:
|
||||
return self.packages
|
||||
if "resolve-activity" in cmd:
|
||||
return self.launcher
|
||||
if "pm dump" in cmd:
|
||||
import shlex
|
||||
|
||||
for package, dump in self.dumps.items():
|
||||
if shlex.quote(package) in cmd or package in cmd:
|
||||
return dump
|
||||
return "__NSH_EXIT:0\n" # ran, no label
|
||||
return None
|
||||
|
||||
|
||||
def _manager(poller):
|
||||
return ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||
|
||||
|
||||
class TestPackageListParsing:
|
||||
"""`pm list packages -3`."""
|
||||
|
||||
def test_real_output_parses_one_package_per_line(self):
|
||||
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",
|
||||
]
|
||||
|
||||
def test_noise_and_duplicates_are_tolerated(self):
|
||||
noisy = (
|
||||
"adb: connecting to 10.0.0.9:5555\n"
|
||||
"package:com.a\n"
|
||||
"\n"
|
||||
"package:com.a\n"
|
||||
"package:com.b \n"
|
||||
"some warning line\n"
|
||||
)
|
||||
assert parse_package_list(noisy) == ["com.a", "com.b"]
|
||||
|
||||
def test_empty_and_none_are_an_empty_list(self):
|
||||
assert parse_package_list(None) == []
|
||||
assert parse_package_list("") == []
|
||||
|
||||
def test_a_package_id_is_sanity_checked(self):
|
||||
assert is_launchable("com.example.app")
|
||||
assert is_launchable("ar.tvplayer.tv")
|
||||
assert not is_launchable("")
|
||||
assert not is_launchable("notapackage")
|
||||
assert not is_launchable("com..b")
|
||||
|
||||
|
||||
class TestLauncherParsing:
|
||||
"""`cmd package resolve-activity --brief -c ...HOME`."""
|
||||
|
||||
def test_the_component_after_the_verbose_header_is_the_launcher(self):
|
||||
assert parse_active_launcher(LAUNCHER_RESOLVED) == "com.google.android.tvlauncher"
|
||||
|
||||
def test_activity_only_output_still_parses(self):
|
||||
assert parse_active_launcher("com.google.android.tvlauncher/.MainActivity") == (
|
||||
"com.google.android.tvlauncher"
|
||||
)
|
||||
|
||||
def test_nothing_resolved_is_none(self):
|
||||
assert parse_active_launcher("No activity found") is None
|
||||
assert parse_active_launcher(None) is None
|
||||
|
||||
def test_the_tv_settings_entry_point_is_not_a_launcher(self):
|
||||
# a HOME resolution can land on the settings app's entry point; that is
|
||||
# not the app a user opens
|
||||
output = "com.android.tv.settings/.MainActivity"
|
||||
assert parse_active_launcher(output) is None
|
||||
|
||||
|
||||
class TestInstalledPackagesMerge:
|
||||
"""User apps plus the active launcher, de-duplicated."""
|
||||
|
||||
def test_launcher_is_added_last(self):
|
||||
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
|
||||
assert packages[-1] == "com.google.android.tvlauncher"
|
||||
assert packages[0] == "ar.tvplayer.tv"
|
||||
|
||||
def test_a_third_party_launcher_is_not_duplicated(self):
|
||||
user = "package:com.example.launcher\npackage:com.a\n"
|
||||
launcher = "com.example.launcher/.MainActivity"
|
||||
assert installed_packages(user, launcher) == ["com.example.launcher", "com.a"]
|
||||
|
||||
def test_no_launcher_still_yields_the_user_apps(self):
|
||||
assert installed_packages(USER_PACKAGES, None) == parse_package_list(USER_PACKAGES)
|
||||
|
||||
|
||||
class TestIconHeuristic:
|
||||
"""Icons are a curated map plus a generic fallback — and always a string."""
|
||||
|
||||
def test_a_known_package_gets_its_curated_icon(self):
|
||||
assert icon_for_package("com.netflix.ninja", APP_ICON_FALLBACKS) == "mdi:netflix"
|
||||
|
||||
def test_an_unknown_package_gets_the_generic_icon(self):
|
||||
assert icon_for_package("com.example.handmade", APP_ICON_FALLBACKS) == "mdi:television"
|
||||
|
||||
def test_the_result_is_always_a_string(self):
|
||||
for package in ("", None, "com.example.handmade", "ar.tvplayer.tv"):
|
||||
icon = icon_for_package(package, APP_ICON_FALLBACKS)
|
||||
assert isinstance(icon, str) and icon
|
||||
|
||||
|
||||
class TestDiscoverySweep:
|
||||
"""The bounded sweep on the connection manager."""
|
||||
|
||||
async def test_a_sweep_lists_labels_and_icons_every_app(self):
|
||||
poller = _DiscoveryPoller(dumps={
|
||||
"ar.tvplayer.tv": DUMP_TIVIMATE,
|
||||
"com.example.handmade": DUMP_LABEL_LESS,
|
||||
})
|
||||
manager = _manager(poller)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
packages = [row["package"] for row in rows]
|
||||
assert "ar.tvplayer.tv" in packages
|
||||
assert "com.netflix.ninja" in packages
|
||||
assert "com.google.android.tvlauncher" in packages # the launcher is there
|
||||
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
|
||||
for row in rows:
|
||||
assert isinstance(row["name"], str) and row["name"]
|
||||
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."""
|
||||
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
packages = {row["package"] for row in rows}
|
||||
|
||||
# the curated list the integration used to fall back to
|
||||
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
|
||||
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
|
||||
|
||||
async def test_the_sweep_issues_the_documented_commands(self):
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager.async_refresh_installed_apps()
|
||||
|
||||
assert "pm list packages -3" in poller.shell_calls
|
||||
assert any("resolve-activity" in cmd for cmd in poller.shell_calls)
|
||||
assert "android.intent.category.HOME" in " ".join(poller.shell_calls)
|
||||
|
||||
async def test_labels_are_fetched_in_a_bounded_burst(self):
|
||||
"""A full sweep must not serialise one shell at a time, and must not flood."""
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
concurrent = {"now": 0, "peak": 0, "total": 0}
|
||||
|
||||
real_shell = poller.shell
|
||||
|
||||
async def _counted(cmd):
|
||||
if "pm dump" in cmd:
|
||||
concurrent["now"] += 1
|
||||
concurrent["total"] += 1
|
||||
concurrent["peak"] = max(concurrent["peak"], concurrent["now"])
|
||||
try:
|
||||
await asyncio.sleep(0) # yield so overlap is observable
|
||||
return await real_shell(cmd)
|
||||
finally:
|
||||
concurrent["now"] -= 1
|
||||
return await real_shell(cmd)
|
||||
|
||||
poller.shell = _counted
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert len(rows) >= 5
|
||||
assert concurrent["total"] >= 5
|
||||
# bounded: never more than the configured workers in flight at once
|
||||
from nsh.const import APP_SWEEP_CONCURRENCY
|
||||
|
||||
assert concurrent["peak"] <= APP_SWEEP_CONCURRENCY
|
||||
assert concurrent["peak"] > 1 # ...and genuinely concurrent, not serial
|
||||
|
||||
async def test_a_cached_table_is_reused_and_costs_no_shell(self):
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
|
||||
first = await manager.async_refresh_installed_apps()
|
||||
calls = len(poller.shell_calls)
|
||||
second = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert second == first
|
||||
assert len(poller.shell_calls) == calls # cached: zero new shells
|
||||
assert manager.installed_apps_stale is False
|
||||
|
||||
async def test_force_refreshes_even_a_fresh_table(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager.async_refresh_installed_apps()
|
||||
calls = len(poller.shell_calls)
|
||||
await manager.async_refresh_installed_apps(force=True)
|
||||
|
||||
assert len(poller.shell_calls) > calls
|
||||
|
||||
async def test_a_stale_table_is_not_served(self):
|
||||
now = [1000.0]
|
||||
poller = _DiscoveryPoller()
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||
)
|
||||
await manager.async_refresh_installed_apps()
|
||||
assert manager.installed_apps()
|
||||
|
||||
now[0] += APP_LIST_TTL + 1
|
||||
assert manager.installed_apps() == []
|
||||
assert manager.installed_apps_stale is True
|
||||
|
||||
async def test_the_watchdog_never_sweeps(self):
|
||||
"""Acceptance: nothing discovery-related runs on a state poll.
|
||||
|
||||
The watchdog cycle is what a poll drives. It may drain ONE label fetch
|
||||
(the t24/t55 rule for the live entity path), but it must never list
|
||||
packages or fan out into a discovery burst.
|
||||
"""
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert not [c for c in poller.shell_calls if "pm list packages" in c]
|
||||
assert not [c for c in poller.shell_calls if "resolve-activity" in c]
|
||||
assert manager.installed_apps() == []
|
||||
|
||||
async def test_concurrent_callers_share_one_sweep(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
list_calls = {"n": 0}
|
||||
real_shell = poller.shell
|
||||
|
||||
async def _counted(cmd):
|
||||
if "pm list packages" in cmd:
|
||||
list_calls["n"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return await real_shell(cmd)
|
||||
|
||||
poller.shell = _counted
|
||||
results = await asyncio.gather(
|
||||
manager.async_refresh_installed_apps(force=True),
|
||||
manager.async_refresh_installed_apps(force=True),
|
||||
manager.async_refresh_installed_apps(force=True),
|
||||
)
|
||||
|
||||
assert list_calls["n"] == 1 # one sweep, shared
|
||||
assert results[0] == results[1] == results[2]
|
||||
|
||||
|
||||
class TestDiscoveryDegradesGracefully:
|
||||
"""ADB off, down or mute: an empty list and a note, never a crash or a loop.
|
||||
|
||||
t62 F1: "could not ask" and "asked, nothing there" are different answers.
|
||||
Only the second one is cached; the first is retried.
|
||||
"""
|
||||
|
||||
async def test_no_poller_is_an_empty_list(self):
|
||||
manager = ShieldConnectionManager(hass=None, entry=None, poller=None)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert rows == []
|
||||
assert manager.installed_apps() == []
|
||||
|
||||
async def test_a_disconnected_poller_is_an_empty_list(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
manager._poller = poller
|
||||
poller.connected = False
|
||||
# the manager's own connected flag is what the sweep reads
|
||||
assert manager.connected is False
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert rows == []
|
||||
assert poller.shell_calls == [] # nothing was even attempted
|
||||
|
||||
async def test_a_mute_transport_is_spaced_but_not_stamped(self):
|
||||
"""t62 F1: a failed sweep is retried — but not once per state poll."""
|
||||
now = [1000.0]
|
||||
poller = _DiscoveryPoller(failure=True)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||
)
|
||||
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert rows == []
|
||||
assert poller.shell_calls # it tried
|
||||
assert manager.installed_apps_status == "unknown" # NOT an empty answer
|
||||
# the next poll inside the window does NOT re-shell (no hammering)..
|
||||
calls = len(poller.shell_calls)
|
||||
assert await manager.async_refresh_installed_apps() == []
|
||||
assert len(poller.shell_calls) == calls
|
||||
# ..but nothing was stamped as an answer, so once the window passes the
|
||||
# sweep runs again. Pre-fix it waited out APP_LIST_TTL (6 h) instead.
|
||||
now[0] += manager.APP_SWEEP_RETRY_SECONDS + 1
|
||||
assert await manager.async_refresh_installed_apps() == []
|
||||
assert len(poller.shell_calls) > calls
|
||||
|
||||
async def test_a_sweep_that_could_not_run_is_not_a_fresh_answer(self):
|
||||
"""t62 F1 (red proof): startup with ADB down must not BE the answer.
|
||||
|
||||
The entity's one startup sweep runs with the poller down. Once ADB comes
|
||||
back, ``homeassistant.update_entity`` (``sensor.async_update``) must
|
||||
recover the table. Pre-fix the startup sweep stamped an empty answer, so
|
||||
this call issued ZERO shells and kept reporting 0/"pending" for
|
||||
APP_LIST_TTL = 6 h.
|
||||
"""
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
poller.connected = False
|
||||
manager = _manager(poller)
|
||||
|
||||
assert await manager.async_refresh_installed_apps() == [] # the startup
|
||||
assert poller.shell_calls == [] # it was not even attempted
|
||||
assert manager.installed_apps_stale is True # ...so it is not an answer
|
||||
assert manager.installed_apps_status == "unknown"
|
||||
|
||||
poller.connected = True # ADB is back
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
|
||||
assert rows, "update_entity must recover the table once ADB returns"
|
||||
assert "pm list packages -3" in poller.shell_calls
|
||||
assert manager.installed_apps_status == "ok"
|
||||
|
||||
async def test_a_genuinely_empty_answer_is_ok(self):
|
||||
"""t62 F1: 'ok' must be reachable for a really empty device."""
|
||||
poller = _DiscoveryPoller(packages="", launcher="")
|
||||
manager = _manager(poller)
|
||||
|
||||
assert await manager.async_refresh_installed_apps() == []
|
||||
|
||||
assert poller.shell_calls # the commands RAN and answered
|
||||
assert manager.installed_apps_stale is False # stamped: a real answer
|
||||
assert manager.installed_apps_status == "ok"
|
||||
|
||||
async def test_the_accessor_never_shells(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
|
||||
assert manager.installed_apps() == []
|
||||
assert poller.shell_calls == []
|
||||
|
||||
|
||||
class TestDiscoveryUsesTheCachedLabelPath:
|
||||
"""Acceptance: labels come from the pm-dump path AND its 24 h cache."""
|
||||
|
||||
async def test_a_second_sweep_reuses_the_cached_labels(self):
|
||||
"""The 24 h cache is what makes a re-sweep cheap — negatives included.
|
||||
|
||||
A label-less package is a DEFINITIVE answer, so the second sweep must
|
||||
not re-dump it. The check goes through the cache directly, because the
|
||||
display accessor (`app_label`) cannot tell "no label" from "not asked".
|
||||
"""
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager.async_refresh_installed_apps()
|
||||
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
|
||||
|
||||
# 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)
|
||||
manager._installed_apps = None # forget the table, keep the labels
|
||||
rows = await manager.async_refresh_installed_apps(force=True)
|
||||
|
||||
assert [c for c in poller.shell_calls if "pm dump" in c] == dumps
|
||||
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"
|
||||
|
||||
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'"})
|
||||
manager = _manager(poller)
|
||||
real_shell = poller.shell
|
||||
state = {"fail_kodi": True}
|
||||
|
||||
async def _flaky(cmd):
|
||||
if state["fail_kodi"] and "pm dump" in cmd and "kodi" in cmd:
|
||||
poller.shell_calls.append(cmd)
|
||||
return None # the transport delivered nothing
|
||||
return await real_shell(cmd)
|
||||
|
||||
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 manager.app_label("org.xbmc.kodi") is None # NOT cached
|
||||
|
||||
state["fail_kodi"] = False
|
||||
manager._installed_apps = None
|
||||
rows2 = await manager.async_refresh_installed_apps()
|
||||
by_pkg2 = {row["package"]: row for row in rows2}
|
||||
assert by_pkg2["org.xbmc.kodi"]["name"] == "Kodi" # recovered
|
||||
|
||||
|
||||
class TestInstalledAppsSensor:
|
||||
"""The dashboard surface: state = count, attribute = the table."""
|
||||
|
||||
def _sensor(self, manager, options=None):
|
||||
sensor_module = load_entity_modules()["sensor"]
|
||||
entry = type("E", (), {})()
|
||||
entry.entry_id = "e1"
|
||||
entry.options = dict(options or {})
|
||||
entry.runtime_data = manager
|
||||
entry.title = "mdarcy"
|
||||
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
|
||||
sensor_module.NvidiaShieldAppsSensor
|
||||
)
|
||||
sensor.hass = entity_stublib.FakeHass()
|
||||
sensor._entry = entry
|
||||
return sensor
|
||||
|
||||
async def test_state_is_the_count_and_the_attribute_is_the_table(self):
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
await manager.async_refresh_installed_apps()
|
||||
sensor = self._sensor(manager)
|
||||
|
||||
assert sensor.native_value == len(manager.installed_apps())
|
||||
attrs = sensor.extra_state_attributes
|
||||
assert attrs["discovery"] == "ok"
|
||||
assert any(
|
||||
app["package"] == "ar.tvplayer.tv" and app["name"] == "TiviMate"
|
||||
for app in attrs["installed_apps"]
|
||||
)
|
||||
|
||||
def test_no_discovery_yet_is_an_empty_table_with_a_reason(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
sensor = self._sensor(manager)
|
||||
|
||||
assert sensor.native_value == 0
|
||||
attrs = sensor.extra_state_attributes
|
||||
assert attrs["installed_apps"] == []
|
||||
assert attrs["discovery"] in ("pending", "not_connected", "adb_disabled")
|
||||
|
||||
async def test_an_empty_discovery_is_ok_not_pending(self):
|
||||
"""t62 F1: a sweep that RAN and found nothing is the answer 'ok'."""
|
||||
poller = _DiscoveryPoller(packages="", launcher="")
|
||||
manager = _manager(poller)
|
||||
await manager.async_refresh_installed_apps()
|
||||
sensor = self._sensor(manager, {"adb_enabled": True})
|
||||
|
||||
assert sensor.native_value == 0
|
||||
assert sensor.extra_state_attributes == {
|
||||
"installed_apps": [],
|
||||
"discovery": "ok",
|
||||
}
|
||||
|
||||
async def test_a_sweep_that_could_not_run_is_unknown_not_pending(self):
|
||||
"""t62 F1: 'pending' used to cover both "running" and "could not run"."""
|
||||
poller = _DiscoveryPoller(failure=True)
|
||||
manager = _manager(poller)
|
||||
await manager.async_refresh_installed_apps()
|
||||
sensor = self._sensor(manager, {"adb_enabled": True})
|
||||
|
||||
assert sensor.extra_state_attributes == {
|
||||
"installed_apps": [],
|
||||
"discovery": "unknown",
|
||||
}
|
||||
|
||||
def test_adb_disabled_is_reported_as_such(self):
|
||||
poller = _DiscoveryPoller()
|
||||
manager = _manager(poller)
|
||||
poller.connected = False
|
||||
sensor = self._sensor(manager, {"adb_enabled": False})
|
||||
|
||||
attrs = sensor.extra_state_attributes
|
||||
|
||||
assert attrs == {"installed_apps": [], "discovery": "adb_disabled"}
|
||||
|
||||
async def test_the_sensor_refresh_is_a_no_op_without_a_manager(self):
|
||||
sensor = self._sensor(None)
|
||||
sensor._entry.runtime_data = None
|
||||
|
||||
assert sensor.native_value == 0
|
||||
assert sensor.extra_state_attributes["installed_apps"] == []
|
||||
await sensor.async_update() # must not raise
|
||||
|
||||
|
||||
class TestDiscoveredAppsStillRespectTheUsersMap:
|
||||
"""The user's own map is an OVERRIDE, and discovery fills the rest (t57)."""
|
||||
|
||||
async def test_a_mapped_package_keeps_the_users_name_not_the_device_label(self):
|
||||
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||
manager = _manager(poller)
|
||||
await manager.async_refresh_installed_apps()
|
||||
|
||||
# the shared resolver is what the sensor and the player use; discovery
|
||||
# only supplies the device label underneath the user's map
|
||||
from nsh.media_player import resolve_app_name
|
||||
|
||||
options = {CONF_APP_NAME_MAP: {"ar.tvplayer.tv": "Telly"}}
|
||||
assert (
|
||||
resolve_app_name(
|
||||
"ar.tvplayer.tv", options[CONF_APP_NAME_MAP], manager.app_label
|
||||
)
|
||||
== "Telly"
|
||||
)
|
||||
# ...and the device label is what discovery put UNDER it, so dropping
|
||||
# the map entry still resolves to a real name rather than a package id
|
||||
assert (
|
||||
resolve_app_name("ar.tvplayer.tv", {}, manager.app_label) == "TiviMate"
|
||||
)
|
||||
|
||||
|
||||
def test_the_label_exit_marker_never_becomes_a_name():
|
||||
"""The marker is a transport detail; it must not leak into a label."""
|
||||
from nsh.ha_adb_common import parse_application_label
|
||||
|
||||
assert parse_application_label("__NSH_EXIT:0\n") is None
|
||||
assert parse_application_label(DUMP_TIVIMATE + "__NSH_EXIT:0\n") == "TiviMate"
|
||||
|
||||
|
||||
def test_importing_the_discovery_module_needs_no_home_assistant():
|
||||
"""The parsers live in ha_adb_common and stay importable without HA."""
|
||||
import nsh.ha_adb_common.installed_apps as module
|
||||
|
||||
assert module.__doc__ and "pm list packages -3" in module.__doc__
|
||||
assert not [n for n in dir(module) if n.startswith("hass")]
|
||||
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
|
||||
assert sessions[0].position is None
|
||||
|
||||
|
||||
class TestPlaybackStateBufferingT55:
|
||||
"""t55: Android PlaybackState 6 is BUFFERING, mapped in the parser.
|
||||
|
||||
The Shield's own sessions report the same integer stock Android does (6),
|
||||
so no vendor-specific variant is treated as buffering — a device variant
|
||||
would be a template field on the same PlaybackState line, never a
|
||||
different integer.
|
||||
"""
|
||||
|
||||
def _buffering_dump(self, state=6, speed="0.0"):
|
||||
return (
|
||||
"Sessions Stack - have 1 sessions:\n"
|
||||
" Some app session com.netflix.ninja/Session (userId=0)\n"
|
||||
" package=com.netflix.ninja\n"
|
||||
" active=true\n"
|
||||
f" state=PlaybackState {{state={state}, position=0, "
|
||||
f"buffered position=2000, speed={speed}, updated=900, actions=1, "
|
||||
"custom actions=[], active item id=0, error=null}\n"
|
||||
" metadata: size=2, description=Movie Title, Some Artist\n"
|
||||
)
|
||||
|
||||
def test_state_6_is_buffering(self):
|
||||
sessions = parse_all_media_sessions(self._buffering_dump())
|
||||
assert len(sessions) == 1
|
||||
session = sessions[0]
|
||||
assert session.buffering is True
|
||||
assert session.playing is False
|
||||
assert session.paused is False
|
||||
assert session.title == "Movie Title"
|
||||
|
||||
def test_state_3_and_2_stay_playing_and_paused(self):
|
||||
for state, attr in ((3, "playing"), (2, "paused")):
|
||||
sessions = parse_all_media_sessions(self._buffering_dump(state=state))
|
||||
session = sessions[0]
|
||||
assert session.buffering is False
|
||||
assert getattr(session, attr) is True
|
||||
|
||||
def test_state_6_wins_when_the_dump_says_speed_zero_too(self):
|
||||
"""The buffering signal is the integer, not the speed field."""
|
||||
sessions = parse_all_media_sessions(self._buffering_dump(state=6))
|
||||
assert sessions[0].buffering is True
|
||||
|
||||
def test_buffering_outranks_a_paused_session_in_the_pick_order(self):
|
||||
sessions = parse_all_media_sessions(
|
||||
self._buffering_dump()
|
||||
+ " Other app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||
"error=null}\n"
|
||||
" metadata: size=1, description=Paused Tune\n"
|
||||
)
|
||||
assert [s.package for s in sessions] == [
|
||||
"com.netflix.ninja",
|
||||
"com.example.player",
|
||||
]
|
||||
# and parse_media_session (no foreground preference) picks buffering
|
||||
dump = self._buffering_dump() + (
|
||||
" Other app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||
"error=null}\n"
|
||||
" metadata: size=1, description=Paused Tune\n"
|
||||
)
|
||||
assert parse_media_session(dump).package == "com.netflix.ninja"
|
||||
|
||||
|
||||
class TestAudioPlaybackSection:
|
||||
"""The "Audio playback (lastly played comes first)" section lists apps
|
||||
that recently played audio even with no MediaSession (R2)."""
|
||||
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
|
||||
assert parse_audio_playback("") == []
|
||||
|
||||
|
||||
def test_running_apps_buffering_state_t55():
|
||||
"""t55: a buffering session (and a buffering last-known) is named buffering."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||
|
||||
tasks = [
|
||||
{"package": "com.netflix.ninja"},
|
||||
{"package": "ar.tvplayer.tv"},
|
||||
]
|
||||
sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)]
|
||||
last_known = {
|
||||
"ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True)
|
||||
}
|
||||
|
||||
apps = running_apps_from_tasks(
|
||||
tasks, sessions, foreground_package=None, last_known=last_known
|
||||
)
|
||||
|
||||
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
|
||||
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
|
||||
assert netflix["state"] == "buffering"
|
||||
assert tivi["last_known_state"] == "buffering"
|
||||
|
||||
|
||||
def test_running_apps_last_known_enrichment():
|
||||
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||
|
||||
+36
-39
@@ -78,17 +78,20 @@ class TestSourceList:
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert "" not in player.source_list
|
||||
|
||||
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
|
||||
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
|
||||
def test_the_list_is_the_normalized_directory_exactly(self, modules):
|
||||
"""Same accessor, same order, same bytes — a blank entry excepted (t49).
|
||||
|
||||
t61 removed the browse tree that used to be the second consumer of this
|
||||
one accessor; the dropdown and the reverse lookup are still fed from it,
|
||||
so a row can only launch the app it shows.
|
||||
"""
|
||||
for app_map in (
|
||||
None, # the curated default
|
||||
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player.source_list == [child.title for child in root.children]
|
||||
assert player.source_list == list(player._app_directory.values())
|
||||
|
||||
async def test_the_survivors_still_resolve_and_launch(self, modules):
|
||||
"""The filter must not cost a resolvable name (t49)."""
|
||||
@@ -144,10 +147,7 @@ class TestSource:
|
||||
|
||||
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
|
||||
player, _served = _player(modules, foreground="com.netflix.ninja")
|
||||
player._manager._app_labels["com.netflix.ninja"] = (
|
||||
"Netflix",
|
||||
player._manager._clock(),
|
||||
)
|
||||
player._manager.cache_app_label("com.netflix.ninja", "Netflix")
|
||||
|
||||
assert player.source == "Netflix"
|
||||
|
||||
@@ -282,6 +282,11 @@ class TestNormalizedDirectories:
|
||||
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
|
||||
name) and is pinned here, together with the promise that a well-formed map is
|
||||
untouched.
|
||||
|
||||
t61 left two surfaces instead of three: the Browse tree that used to be the
|
||||
second view is gone (apps are sources), so the pins below anchor the same
|
||||
policy on `_app_directory` — the shared accessor itself — and on
|
||||
`source_list`/`_source_package`, which are what a user can still reach.
|
||||
"""
|
||||
|
||||
BLANKED = {
|
||||
@@ -291,25 +296,21 @@ class TestNormalizedDirectories:
|
||||
"": "Ghost",
|
||||
}
|
||||
|
||||
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
|
||||
"""The Browse tree and the dropdown are the same, resolved offer."""
|
||||
def test_a_blank_entry_never_reaches_the_dropdown(self, modules):
|
||||
"""Blanks are dropped once, in the accessor every offer reads (t49)."""
|
||||
player, _served = _player(modules, app_map=dict(self.BLANKED))
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.title for child in root.children] == ["TiviMate"]
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert list(player._app_directory) == [TIVIMATE]
|
||||
assert list(player._app_directory.values()) == ["TiviMate"]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_blank_package_is_dropped_too(self, modules):
|
||||
"""An entry with no package is a child that cannot be launched."""
|
||||
def test_a_blank_package_is_dropped_too(self, modules):
|
||||
"""An entry with no package is a row that cannot be launched."""
|
||||
player, _served = _player(
|
||||
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert list(player._app_directory) == [TIVIMATE]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
|
||||
@@ -318,11 +319,9 @@ class TestNormalizedDirectories:
|
||||
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# display and match key are one string, everywhere
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
|
||||
assert list(player._app_directory.values()) == ["TiviMate", "Netflix"]
|
||||
# ...so every spelling the user may type finds the package
|
||||
assert player._source_package("TiviMate") == TIVIMATE
|
||||
assert player._source_package(" TiviMate ") == TIVIMATE
|
||||
@@ -338,13 +337,11 @@ class TestNormalizedDirectories:
|
||||
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# both are still offered in the tree, each by its own package id
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Netflix"),
|
||||
("com.b", "Netflix"),
|
||||
]
|
||||
# t61: the directory keeps BOTH entries, each under its own package id;
|
||||
# the removal of the browse tree does not remove the reachability it
|
||||
# used to expose — select_source-by-id is that surface now.
|
||||
assert player._app_directory == {"com.a": "Netflix", "com.b": "Netflix"}
|
||||
assert player._source_package("com.b") == "com.b" # the shadowed package
|
||||
# the dropdown offers the name once, and resolves to the first entry
|
||||
assert player.source_list == ["Netflix"]
|
||||
assert player._source_package("Netflix") == "com.a"
|
||||
@@ -366,10 +363,11 @@ class TestNormalizedDirectories:
|
||||
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player._app_directory == {
|
||||
TIVIMATE: "TiviMate Pro",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
|
||||
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||
|
||||
|
||||
@@ -389,12 +387,11 @@ class TestCaseInsensitiveNames:
|
||||
# one row, keeping the first spelling the user typed
|
||||
assert player.source_list == ["Dup"]
|
||||
|
||||
# ...and the Browse tree still holds both apps, each under its own id
|
||||
root = await player.async_browse_media()
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Dup"),
|
||||
("com.b", "dup"),
|
||||
]
|
||||
# ...and the directory still holds both apps, each under its own id, so
|
||||
# the shadowed one stays reachable by its package id (t61: that is what
|
||||
# the removed browse tree used to publish)
|
||||
assert player._app_directory == {"com.a": "Dup", "com.b": "dup"}
|
||||
assert player._source_package("com.b") == "com.b"
|
||||
|
||||
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
@@ -61,3 +61,12 @@ def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
||||
assert needle in readme_text, needle
|
||||
# and it says the UI is the primary path
|
||||
assert "primary path" in readme_text
|
||||
|
||||
|
||||
def test_the_readme_states_the_on_rule_precisely(readme_text):
|
||||
"""t58 UNIT 0: "no data" was too loose; the README must not still say it."""
|
||||
assert "no source reported a power or playback fact" in readme_text
|
||||
# the four reporting-but-not-playing shapes, named
|
||||
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
|
||||
@@ -0,0 +1,180 @@
|
||||
"""tests/test_receiver_switch.py — a plain switch as the Receiver (t61).
|
||||
|
||||
The user's request, verbatim: "it would be nice if a receiver could just be a
|
||||
switch, I don't need my volume scripts then because the receiver portion could
|
||||
just make sure the receiver is on if it's a switch". So the playback entry
|
||||
points — ``turn_on``, ``play_media`` and ``select_source`` — ensure a
|
||||
``switch.*`` receiver is ON, and only when it is actually off.
|
||||
|
||||
The four states the contract names are all pinned here:
|
||||
|
||||
* switch off + playback starts → exactly one ``switch.turn_on``;
|
||||
* switch already on → NO call (idempotent, not a toggle);
|
||||
* switch unavailable/unknown/missing/failing → playback continues, nothing raises;
|
||||
* a ``media_player.*`` receiver → never switched; its behaviour is unchanged.
|
||||
|
||||
No real Shield, no real switch and no browser take part: the entity comes from
|
||||
the same stub harness every other entity test uses, the receiver's state is a
|
||||
value in ``FakeStates`` and the service call is recorded.
|
||||
"""
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import CONF_APP_NAME_MAP, CONF_RECEIVER_ENTITY
|
||||
from test_command_routing import make_player
|
||||
|
||||
TIVIMATE = "ar.tvplayer.tv"
|
||||
SOUNDBAR = "switch.soundbar"
|
||||
AVR = "media_player.avr"
|
||||
|
||||
|
||||
@pytest.fixture(name="modules", autouse=True)
|
||||
def scoped_modules():
|
||||
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
try:
|
||||
yield entity_stublib.load_entity_modules()
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _player(modules, *, receiver=SOUNDBAR, state=None, app_map=None):
|
||||
"""A player with a Receiver configured (a switch unless told otherwise)."""
|
||||
player, served, _intents = make_player(modules)
|
||||
player._entry.options[CONF_RECEIVER_ENTITY] = receiver
|
||||
if app_map is not None:
|
||||
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||
if state is not None:
|
||||
player.hass.states.set(receiver, state)
|
||||
return player, served
|
||||
|
||||
|
||||
class TestTheSwitchReceiverIsEnsuredOn:
|
||||
"""Every playback entry point turns an off switch receiver on — once."""
|
||||
|
||||
async def test_play_media_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
# the ensure is the ONLY service call: no volume, no toggle, and the
|
||||
# app still launches (which is why the volume scripts are unnecessary)
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_turn_on_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
|
||||
async def test_select_source_turns_an_off_receiver_on_once(self, modules):
|
||||
"""select_source dispatches internally, so the switch is read once."""
|
||||
player, served = _player(
|
||||
modules, state="off", app_map={TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_an_on_receiver_is_never_called(self, modules):
|
||||
"""Idempotent: every entry point may run, and NOT ONE call goes out."""
|
||||
player, served = _player(modules, state="on")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_no_volume_service_is_ever_aimed_at_the_receiver(self, modules):
|
||||
"""The user's point: a switch receiver means no volume scripts."""
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert [(domain, service) for domain, service, _data in served] == [
|
||||
("switch", "turn_on")
|
||||
]
|
||||
|
||||
|
||||
class TestAnUnusableReceiverNeverBreaksPlayback:
|
||||
"""A receiver we cannot read, or that fails, is logged — never raised."""
|
||||
|
||||
async def test_an_unavailable_switch_is_left_alone(self, modules):
|
||||
player, served = _player(modules, state="unavailable")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == [] # never guessed at
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE] # still plays
|
||||
|
||||
async def test_an_unknown_state_is_not_guessed_at(self, modules):
|
||||
player, served = _player(modules, state="unknown")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_a_missing_entity_is_left_alone(self, modules):
|
||||
player, served = _player(modules) # the receiver is not in the machine
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_a_failing_service_call_does_not_stop_playback(self, modules):
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
player, _served = _player(modules, state="off")
|
||||
|
||||
class _BrokenServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise HomeAssistantError("the switch entity is gone")
|
||||
|
||||
player.hass.services = _BrokenServices()
|
||||
|
||||
await player.async_play_media("app", TIVIMATE) # must not raise
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
|
||||
class TestAMediaPlayerReceiverIsUntouched:
|
||||
"""An AVR stays an AVR: input selection and enrichment, never a switch call."""
|
||||
|
||||
async def test_a_media_player_receiver_is_never_switched(self, modules):
|
||||
player, served = _player(modules, receiver=AVR, state="off")
|
||||
player.hass.states.set(AVR, "idle")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_the_receiver_option_still_reaches_the_attributes(self, modules):
|
||||
from nsh.const import ATTR_MEDIA_DAG_RECEIVER_ENTITY
|
||||
|
||||
player, _served = _player(modules, receiver=AVR)
|
||||
|
||||
assert (
|
||||
player.extra_state_attributes[ATTR_MEDIA_DAG_RECEIVER_ENTITY] == AVR
|
||||
)
|
||||
|
||||
|
||||
class TestAnIdleShieldLeavesTheReceiverAlone:
|
||||
"""Switching the receiver on is a PLAYBACK action, not a synchronisation."""
|
||||
|
||||
async def test_reading_state_and_attributes_calls_nothing(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
assert player.state is not None # a state read
|
||||
assert isinstance(player.extra_state_attributes, dict) # what the frontend reads
|
||||
player._on_manager_update() # the connection manager pushed an update
|
||||
|
||||
assert served == []
|
||||
+135
-19
@@ -27,6 +27,8 @@ from nsh.const import (
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
SCRIPT_COMMANDS,
|
||||
SCRIPT_FIELD_PREFIX,
|
||||
)
|
||||
|
||||
_STUB_NAMES = (
|
||||
@@ -101,6 +103,19 @@ def _install_stubs() -> None:
|
||||
"description_placeholders": dict(description_placeholders or {}),
|
||||
}
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape — the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
@@ -136,12 +151,29 @@ def _install_stubs() -> None:
|
||||
raise DiscoveryDedupAbortError("already_in_progress")
|
||||
|
||||
class OptionsFlow:
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
def async_show_form(
|
||||
self, *, step_id, data_schema=None, errors=None,
|
||||
description_placeholders=None,
|
||||
):
|
||||
return {
|
||||
"type": "form",
|
||||
"step_id": step_id,
|
||||
"errors": dict(errors or {}),
|
||||
"data_schema": data_schema,
|
||||
"description_placeholders": dict(description_placeholders or {}),
|
||||
}
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape — the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data):
|
||||
@@ -157,6 +189,9 @@ def _install_stubs() -> None:
|
||||
return schema
|
||||
|
||||
class OptionsFlowWithReload(_SuggestedMixin):
|
||||
# t56: the MENU surface is shared with OptionsFlow, like async_show_form.
|
||||
async_show_menu = OptionsFlow.async_show_menu
|
||||
|
||||
def async_create_entry(self, *, title, data):
|
||||
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||
@@ -476,6 +511,82 @@ class TestMigrationDropsTheEntityKey:
|
||||
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
|
||||
|
||||
|
||||
_OPTIONS_SUBSTEP_FIELDS = {
|
||||
"connection": {
|
||||
CONF_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
"adb_store_app_info",
|
||||
"logcat_events",
|
||||
"adb_command_enabled",
|
||||
"jellyfin_entity",
|
||||
"power_entity",
|
||||
},
|
||||
"receiver": {"receiver_entity", "receiver_input"},
|
||||
"apps": {"app_name_map", "app_icon_map"},
|
||||
"scripts": {
|
||||
"remote_command_script",
|
||||
"channel_digit_delay",
|
||||
"channel_send_enter",
|
||||
"command_scripts",
|
||||
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _options_step_of(key: str) -> str:
|
||||
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
|
||||
if key in fields:
|
||||
return step
|
||||
if key.startswith(SCRIPT_FIELD_PREFIX):
|
||||
return "scripts"
|
||||
raise AssertionError(f"no options sub-screen owns {key!r}")
|
||||
|
||||
|
||||
async def _options_submit(flow, payload: dict, *, save=False):
|
||||
"""Drive the t56 options MENU into the sub-screens owning `payload` keys.
|
||||
|
||||
Returns the last step result, so an error assertion reads the same as it did
|
||||
when the page was a single form. With ``save=True`` the menu's "Save and
|
||||
close" row is chosen afterwards, returning the create_entry result.
|
||||
"""
|
||||
grouped: dict[str, dict] = {}
|
||||
for key, value in payload.items():
|
||||
grouped.setdefault(_options_step_of(key), {})[key] = value
|
||||
result = await flow.async_step_init(None)
|
||||
for step, sub in grouped.items():
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
result = await getattr(flow, f"async_step_{step}")(sub)
|
||||
if save:
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await flow.async_step_finish(None)
|
||||
return result
|
||||
|
||||
|
||||
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)
|
||||
assert step in result["menu_options"], result["menu_options"]
|
||||
return await getattr(flow, f"async_step_{step}")(None)
|
||||
|
||||
|
||||
async def _setup_finish(flow, result):
|
||||
"""Walk the t56 setup MENU to the entry, starting from a step result.
|
||||
|
||||
Chooses any pairing row it needs to (the flow's menu_options are the source
|
||||
of truth) and then Finish, returning the create_entry result.
|
||||
"""
|
||||
while result["type"] == "menu":
|
||||
choice = (
|
||||
"finish"
|
||||
if "finish" in result["menu_options"]
|
||||
else result["menu_options"][0]
|
||||
)
|
||||
result = await getattr(flow, f"async_step_{choice}")(None)
|
||||
return result
|
||||
|
||||
|
||||
def _cmd_player(modules):
|
||||
"""Standalone media_player entity via the command-routing harness."""
|
||||
import test_command_routing as routing
|
||||
@@ -496,7 +607,7 @@ class TestAppIconColumn:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
|
||||
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
|
||||
|
||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
||||
@@ -510,7 +621,7 @@ class TestAppIconColumn:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
|
||||
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"})
|
||||
|
||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
|
||||
@@ -540,7 +651,7 @@ class TestAppIconColumn:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
|
||||
result = await _options_submit(flow, {"app_name_map": "p=Name|has spaces"})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
@@ -562,11 +673,11 @@ class TestAppIconColumn:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
||||
result = await _options_submit(
|
||||
flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["type"] == "form" # the Apps sub-screen re-rendered
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
assert "app_name_map" not in flow._options # nothing half-stored
|
||||
|
||||
@@ -592,7 +703,7 @@ class TestChannelEntryOptions:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
|
||||
await _options_submit(flow, {"channel_digit_delay": "0.3", "channel_send_enter": True})
|
||||
|
||||
assert flow._options["channel_digit_delay"] == 0.3
|
||||
assert flow._options["channel_send_enter"] is True
|
||||
@@ -603,7 +714,7 @@ class TestChannelEntryOptions:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
|
||||
result = await _options_submit(flow, {"channel_digit_delay": "2.0"})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||
@@ -614,7 +725,7 @@ class TestChannelEntryOptions:
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await flow.async_step_init({"channel_digit_delay": "slow"})
|
||||
result = await _options_submit(flow, {"channel_digit_delay": "slow"})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||
@@ -1161,12 +1272,14 @@ class TestAppsMapOptionsEditor:
|
||||
async def test_map_text_stored_as_dict(self, tmp_path):
|
||||
flow = self._options_flow(tmp_path, options={})
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}
|
||||
result = await _options_submit(
|
||||
flow,
|
||||
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"},
|
||||
save=True,
|
||||
)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert flow._options["app_name_map"] == {
|
||||
assert result["data"]["app_name_map"] == {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.plex.hdx": "Plex",
|
||||
}
|
||||
@@ -1174,7 +1287,7 @@ class TestAppsMapOptionsEditor:
|
||||
async def test_invalid_map_shows_form_error(self, tmp_path):
|
||||
flow = self._options_flow(tmp_path, options={})
|
||||
|
||||
result = await flow.async_step_init({"app_name_map": "no equals sign here"})
|
||||
result = await _options_submit(flow, {"app_name_map": "no equals sign here"})
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
@@ -1184,10 +1297,10 @@ class TestAppsMapOptionsEditor:
|
||||
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
||||
)
|
||||
|
||||
result = await flow.async_step_init({"app_name_map": " "})
|
||||
result = await _options_submit(flow, {"app_name_map": " "}, save=True)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert flow._options["app_name_map"] == {}
|
||||
assert result["data"]["app_name_map"] == {}
|
||||
|
||||
async def test_existing_map_prefills_the_text_field(self, tmp_path):
|
||||
flow = self._options_flow(
|
||||
@@ -1195,10 +1308,13 @@ class TestAppsMapOptionsEditor:
|
||||
)
|
||||
|
||||
# first visit: the form carries the serialized current map as the
|
||||
# field default
|
||||
result = await flow.async_step_init(None)
|
||||
# field default — NEVER the dict (that is the [object Object] bug)
|
||||
result = await _options_open(flow, "apps")
|
||||
|
||||
assert result["type"] == "form"
|
||||
fields = getattr(result["data_schema"], "schema", result["data_schema"])
|
||||
suggested = fields.get("__suggested__", {})
|
||||
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate" # t57
|
||||
# the editor carried the existing map through the defaults path
|
||||
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||
|
||||
@@ -1241,7 +1357,7 @@ class TestAppNameMapResolution:
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {} # no override
|
||||
manager = player._manager
|
||||
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
|
||||
|
||||
manager._on_current_app("ar.tvplayer.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
Reference in new issue
Block a user