Compare commits
53
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 | ||
|
|
49bc23ae34 | ||
|
|
0357f47284 | ||
|
|
dc3cfbbe3e | ||
|
|
97615408c0 | ||
|
|
1c6c223f27 | ||
|
|
c3e4c7855c | ||
|
|
b2aab15ebf | ||
|
|
b3f897c36e | ||
|
|
0cfa045190 | ||
|
|
b03cfbae13 | ||
|
|
89c05f1dcd | ||
|
|
2e1d162ef4 | ||
|
|
fbc72dfd9e | ||
|
|
3175582419 | ||
|
|
7608518d97 | ||
|
|
aea9f9f9d2 | ||
|
|
130dee4bc6 | ||
|
|
151c49a0da | ||
|
|
fb481c3c14 | ||
|
|
81f70585cd | ||
|
|
75ebeb40b0 | ||
|
|
86aa2827a2 | ||
|
|
60d884b76a | ||
|
|
dd23d01a88 | ||
|
|
be3abae3e7 | ||
|
|
acc7bc3d01 |
No files matched your search
+114
-38
@@ -1,10 +1,19 @@
|
||||
# Publish a date-based release every time master moves.
|
||||
# Publish a date-based release every time master moves — and keep HACS able to
|
||||
# see every one of them (t42).
|
||||
#
|
||||
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
|
||||
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
|
||||
# manifest to force a specific release, e.g. after a meaningful change); otherwise
|
||||
# the version is today's date (UTC), so a plain push to master publishes the
|
||||
# day's release. Repeated pushes on the same day are deduped by tag existence.
|
||||
# Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N)
|
||||
# for further releases in the same UTC day. The decision lives in
|
||||
# scripts/determine-release-version.sh so it can be tested locally:
|
||||
# the manifest `version` is untagged -> that version (bump it to force one)
|
||||
# else today's UTC date is untagged -> vYYYY.MM.DD
|
||||
# else -> vYYYY.MM.DD.N, lowest untagged N >= 2
|
||||
#
|
||||
# Why the write-back below exists: the resolved version is written into
|
||||
# custom_components/nvidia_shield/manifest.json and committed BEFORE the tag, so
|
||||
# the released artifact and its tag always agree. Without it HACS compares the
|
||||
# installed version against a manifest that never moves — v2026.10.09 was
|
||||
# published while master's manifest still said 2026.10.08, so the second release
|
||||
# of that day was offered to nobody.
|
||||
#
|
||||
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
|
||||
# nvidia_shield/) so the integration is installable manually from any release.
|
||||
@@ -28,15 +37,28 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# The job token pushes the tag and creates the release.
|
||||
# The job token pushes the manifest commit and the tag, and creates the release.
|
||||
contents: write
|
||||
|
||||
env:
|
||||
ZIP_NAME: nvidia_shield.zip
|
||||
MANIFEST: custom_components/nvidia_shield/manifest.json
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# t42/t44 R3: this job pushes the write-back commit itself, and a run starting
|
||||
# from that commit would release the same version twice. The guard is anchored to
|
||||
# the release commit's SUBJECT (`chore(release): …`), not to the `[skip ci]`
|
||||
# marker anywhere in the message: a developer commit whose body happens to
|
||||
# mention [skip ci] must still release — with contains() it silently did not.
|
||||
# The marker stays in the commit message because it is the convention Gitea
|
||||
# itself may honour to skip the run outright (README documents that platform
|
||||
# layer, and that the job guard is a no-op there rather than the trigger).
|
||||
# workflow_dispatch has no head_commit, hence the first clause.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
!startsWith(github.event.head_commit.message || '', 'chore(release):')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -45,61 +67,115 @@ jobs:
|
||||
ref: master
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Determine release version
|
||||
- name: Determine the release version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
|
||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||
# An unreleased manifest version wins over today's date.
|
||||
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
|
||||
VERSION="${DATE_VERSION}"
|
||||
else
|
||||
VERSION="${MANIFEST_VERSION}"
|
||||
fi
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "v${VERSION} is already tagged — nothing to release"
|
||||
echo "exists=yes" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "releasing v${VERSION}"
|
||||
echo "exists=no" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT
|
||||
# is set inside a step, to this job's outputs too. RELEASE=no means the
|
||||
# version already exists: tag and publish nothing.
|
||||
scripts/determine-release-version.sh
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
- name: Write the version back into the manifest
|
||||
id: bump
|
||||
if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
|
||||
|
||||
python3 - "$MANIFEST" "$VERSION" <<'PY'
|
||||
import json, pathlib, sys
|
||||
path, version = pathlib.Path(sys.argv[1]), sys.argv[2]
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["version"] = version
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
git add "$MANIFEST"
|
||||
git commit -m "chore(release): v${VERSION} [skip ci]"
|
||||
|
||||
# Never tag a commit whose manifest the default branch does not carry:
|
||||
# a branch protection rule (or a push that landed underneath this run)
|
||||
# rejecting this push makes the release impossible, so stop loudly and
|
||||
# say what to do instead of publishing an inconsistent artifact.
|
||||
if ! git push origin HEAD:master; then
|
||||
echo "::error::could not push the release commit for v${VERSION}: the" \
|
||||
"default branch rejected it (branch protection, or it moved during" \
|
||||
"this run). Nothing was tagged or published. Bump ${MANIFEST} to" \
|
||||
"${VERSION} manually, commit it, and re-run this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify the released manifest on master
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read the manifest from the REMOTE default branch, not from the
|
||||
# workspace: that is the copy HACS fetches, and it has to already carry
|
||||
# the version about to be tagged.
|
||||
git fetch --quiet origin master
|
||||
REMOTE_VERSION="$(git show "origin/master:${MANIFEST}" | jq -r '.version')"
|
||||
if [ "$REMOTE_VERSION" != "$VERSION" ]; then
|
||||
echo "::error::master's manifest says ${REMOTE_VERSION} but v${VERSION}" \
|
||||
"is being released — refusing to publish an inconsistent artifact." \
|
||||
"Re-run once master settles." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "master's manifest is ${REMOTE_VERSION}, matching v${VERSION}"
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
# HEAD is the commit the manifest was just verified on: the bumped one
|
||||
# when a write-back was needed, else the pushed commit itself. The tag
|
||||
# and the artifact therefore name the same version.
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
- name: Build the release zip
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd custom_components
|
||||
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
BUMP: ${{ steps.version.outputs.BUMP }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Notes: subject lines of the commits in the released range.
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${TAG}"
|
||||
# Notes: subject lines of the commits in the released range — the
|
||||
# commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
|
||||
# at the bot's own commit, so the range has to stop at its parent or every
|
||||
# release would open with "chore(release): vX [skip ci]". Without one the
|
||||
# tag is the pushed commit itself and belongs in the notes (t44 R1).
|
||||
if [ "$BUMP" = "yes" ]; then
|
||||
NOTES_END="${TAG}^"
|
||||
else
|
||||
RANGE="${TAG}"
|
||||
NOTES_END="${TAG}"
|
||||
fi
|
||||
PREV="$(git describe --tags --abbrev=0 "${NOTES_END}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${NOTES_END}"
|
||||
else
|
||||
RANGE="${NOTES_END}"
|
||||
fi
|
||||
{
|
||||
echo "Release ${TAG}"
|
||||
|
||||
+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
|
||||
@@ -9,7 +9,9 @@ This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/l
|
||||
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
||||
integrations.
|
||||
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based
|
||||
> (`vYYYY.MM.DD`, plus `vYYYY.MM.DD.N` for a second release in the same day) —
|
||||
> see [Releases](#releases) for how a push becomes one.
|
||||
> Expect rough edges until the MVP phases land. The build-out plan is in
|
||||
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||||
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||||
@@ -61,28 +63,56 @@ 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** accepts any one of the three state sources — the Android
|
||||
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
|
||||
media-session mode (ADB enabled + host). The `no_state_source` form error
|
||||
fires only when none of the three is present; ADB-only setups are valid.
|
||||
**Standalone setup** offers both state sources at once — the Nvidia Remote
|
||||
Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
|
||||
Shield's address pre-filled when discovery found it. Either can be turned off,
|
||||
and each pairing step can be skipped (that transport stays off until you pair
|
||||
it), so ADB-only, protocol-only and fully passive entries are all one click
|
||||
apart. The `no_state_source` form error fires only when neither is enabled.
|
||||
|
||||
There is no other integration in the loop: one address, two transports, and the
|
||||
integration reads the device itself.
|
||||
|
||||
### Channel entry options
|
||||
|
||||
@@ -96,17 +126,32 @@ behavior). Both are consumed by `play_media` with
|
||||
|
||||
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
||||
interactive setup flow — nothing is created without the user walking the
|
||||
standard flow, and pairing stays interactive exactly as with UI setup:
|
||||
standard flow, and pairing stays interactive exactly as with UI setup. The UI is
|
||||
the primary path; the block exists so a provisioning/fleet setup can supply the
|
||||
address and the transport choice.
|
||||
|
||||
A complete, copy-pasteable block (every key is optional except an address):
|
||||
|
||||
```yaml
|
||||
nvidia_shield:
|
||||
- name: Living Room
|
||||
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
|
||||
adb_port: 5555
|
||||
- name: Living Room # the entry's title (default: NVIDIA Shield)
|
||||
host: 10.0.0.31 # the Shield's IP — the one address field
|
||||
adb_port: 5555 # optional; the standard ADB port
|
||||
adb_enabled: true # optional; the block's shape decides (see below)
|
||||
```
|
||||
|
||||
`protocol_host` is a prefill hint only (the PIN pairing still happens in
|
||||
the flow). Blocks without protocol/adb hosts are ignored with a warning.
|
||||
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
|
||||
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
|
||||
the address is a prefill hint either way, and the PIN/ADB pairing still happens
|
||||
in the flow. `adb_enabled` defaults to what the block implies — an address under
|
||||
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
|
||||
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
|
||||
address are ignored with a warning.
|
||||
|
||||
The seed carries the address, the ADB toggle and the entry's name; everything
|
||||
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
|
||||
`adb_command` service) is set on the entry's **Configure** page after setup, or
|
||||
during the interactive flow.
|
||||
Duplicate guards: a block matching an existing entry's host (or its
|
||||
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||
host. Removing the YAML block creates or deletes nothing — entries live
|
||||
@@ -126,24 +171,76 @@ documented, opt-in escape hatch for debugging and automation, registered at
|
||||
setup and unregistered when the last participating entity unloads. It
|
||||
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||||
|
||||
### Migration from mediasession2mqtt + universal-player glue
|
||||
## Replacing androidtv / androidtv_remote + the glue
|
||||
|
||||
The `mediasession2mqtt` bridge sensors and the universal media player YAML
|
||||
templates the fork replaces are retired by the ladder above: the media player
|
||||
entity already carries the DAG attributes (`media_dag_running_apps` with
|
||||
foreground/last-known enrichment, `media_dag_now_playing` with its source
|
||||
tag) that media-zone orchestration consumed from those sensors. See the
|
||||
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
|
||||
retirement mapping and the universal-player glue it replaces.
|
||||
This fork replaces the old `androidtv` media player, the `androidtv_remote`
|
||||
integration and the surrounding YAML glue — there is nothing to keep alongside
|
||||
it: the entry's own two transports cover the whole device.
|
||||
|
||||
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
|
||||
2. **Remove the old platform YAML** — the `media_player:` blocks using
|
||||
`platform: androidtv` or `platform: androidtv_remote`, any `universal` media
|
||||
player template that fanned them in, and the `remote:`/`switch:` shims you
|
||||
added around them.
|
||||
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
|
||||
feeding them are replaced by this integration (table below).
|
||||
4. **Delete the old integrations** in Settings > Devices & Services, then
|
||||
**restart** Home Assistant.
|
||||
5. **Add this integration** — Settings > Devices & Services > Add Integration >
|
||||
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
|
||||
then walk the pairing steps.
|
||||
6. **Update your automations and dashboards** to the new entities:
|
||||
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
|
||||
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
|
||||
|
||||
What each retired piece used to provide, and what covers it now:
|
||||
|
||||
| Retired piece | Covered by |
|
||||
|---------------|------------|
|
||||
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) |
|
||||
| `androidtv_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 |
|
||||
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
|
||||
|
||||
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
|
||||
comes from the ADB media session — the same tier every other app uses — so it
|
||||
needs no bridge either.
|
||||
|
||||
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
|
||||
full retirement mapping this section summarises.
|
||||
|
||||
## Releases
|
||||
|
||||
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
||||
- **Date-based versioning**: `vYYYY.MM.DD`, and `vYYYY.MM.DD.N` (N from 2) for a
|
||||
second release in the same UTC day.
|
||||
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
|
||||
run the release workflow (`.github/workflows/release.yml`):
|
||||
- A plain push publishes the current day's release (same-day pushes are deduped).
|
||||
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
|
||||
specific version's release before the date fallback kicks in.
|
||||
run the release workflow (`.gitea/workflows/release.yml`):
|
||||
- A plain push publishes today's release. If that date is already tagged it takes
|
||||
the lowest free `.N`, so a gap left by an earlier failure is reused rather than
|
||||
skipped.
|
||||
- Setting `version` in `custom_components/nvidia_shield/manifest.json` to an
|
||||
untagged value releases exactly that version instead.
|
||||
- **The release commit.** When the resolved version differs from the manifest, the
|
||||
workflow writes it back, commits `chore(release): v<VERSION> [skip ci]`, pushes it
|
||||
to `master`, and tags *that* commit. It has to do this: HACS compares the installed
|
||||
version against the manifest on the default branch, so a release that only ever
|
||||
existed as a tag is offered to nobody (v2026.10.09 shipped that way).
|
||||
- Because the tag names the bot commit, `git describe --tags` on a *developer*
|
||||
commit reports the **previous** release. That is expected, not a missing tag.
|
||||
- Bump it by hand instead and the tag lands on your own commit: set `version` in
|
||||
the manifest, commit, push. The workflow then has nothing to write back (it sees
|
||||
an untagged manifest version and tags your commit).
|
||||
- **`[skip ci]`**: the bot commit carries it, and the workflow's job additionally
|
||||
checks the commit *subject* (`chore(release): …`), so a write-back cannot start a
|
||||
second release. Both only recognise release commits — a normal commit whose message
|
||||
body merely mentions `[skip ci]` can still be skipped by Gitea's own handling, so
|
||||
keep the marker out of ordinary commit messages.
|
||||
- The workflow refuses to publish unless the default branch's manifest already carries
|
||||
the version it is about to tag, and fails loudly (with a "bump the manifest by hand"
|
||||
instruction and nothing tagged) if the write-back push is rejected, for example by
|
||||
branch protection.
|
||||
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
|
||||
installs. (No automatic mirroring — see the HACS note below.)
|
||||
|
||||
@@ -169,10 +266,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
|
||||
## Configuration
|
||||
|
||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||
2. Select the Android TV Remote entity for your Shield
|
||||
3. Enable ADB if you want now-playing from all apps
|
||||
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
||||
5. Optionally configure Jellyfin, receiver, power switch
|
||||
2. Give it a name and its address — the Shield's IP, pre-filled when discovery
|
||||
found the device
|
||||
3. Both transports are on by default: **Read state over ADB** (now-playing from
|
||||
every app) and **Add the Nvidia Remote Protocol** (power, availability, app
|
||||
push, volume readout). Uncheck what you do not want.
|
||||
4. If ADB is enabled, the next step pairs it — approve the prompt on the Shield
|
||||
5. The remote protocol pairs next — type the PIN shown on the Shield, or
|
||||
continue without pairing (that transport stays off until you pair it)
|
||||
6. Optionally configure Jellyfin, receiver, power switch
|
||||
|
||||
### ADB Pairing
|
||||
|
||||
@@ -189,7 +291,7 @@ service, and the discovered address is pre-filled on the setup form. The dedup
|
||||
rules are Home Assistant's own, and they differ with what the discovery carries:
|
||||
|
||||
- **with** the device MAC, a duplicate aborts as "already configured", and an
|
||||
existing entry's hosts are updated when the address changed;
|
||||
existing entry's address is updated when it changed;
|
||||
- **without** a MAC, the flow follows HA's stricter
|
||||
`_async_handle_discovery_without_unique_id` contract: **any** existing entry
|
||||
for this integration aborts the discovery, and only one discovery flow runs at
|
||||
@@ -202,10 +304,294 @@ 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
|
||||
- **ADB Host/Port** — override auto-detection if needed
|
||||
- **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
|
||||
transport on or off
|
||||
- **Shield address / ADB port** — the Shield's IP, and the ADB port if you
|
||||
changed it from 5555
|
||||
- **Command scripts** — one optional script per command, plus one for the remote
|
||||
entity; see below
|
||||
|
||||
### Command scripts (per-command overrides)
|
||||
|
||||
Some commands need hardware this integration deliberately does not drive: a speaker
|
||||
switch that owns the volume, an AVR input that has to move with the Shield, a mute
|
||||
that has to hit two devices. For those, the Configure page takes a **script per
|
||||
command**:
|
||||
|
||||
| Command | Runs when | Variables passed |
|
||||
| --- | --- | --- |
|
||||
| `turn_on`, `turn_off` | turn on/off, from the media player *or* the remote | — |
|
||||
| `volume_up`, `volume_down` | volume step | — |
|
||||
| `volume_set` | absolute volume (there is no native path — the protocol has VOLUME_UP/DOWN/MUTE only) | `level` |
|
||||
| `volume_mute` | mute/unmute | `is_volume_muted` |
|
||||
| `select_source` | source selection (the app list drives it natively; a script overrides) | `app_name` |
|
||||
|
||||
A command with a script calls the script **instead of** the protocol/ADB path, never
|
||||
both. Left unset — the default — every command behaves exactly as it did before, and
|
||||
clearing a selector on the Configure page restores that. Two of the seven appear as
|
||||
services only when there is something to do: `volume_set`, which has no native path at
|
||||
all, and `select_source`, which is offered once the integration has sources to offer
|
||||
(the app list below) — or a script.
|
||||
|
||||
The scripts are ordinary Home Assistant scripts. This block covers all three awkward
|
||||
cases: a speaker switch owning the volume, an AVR owning the input, and a mute that
|
||||
has to hit the switch as well.
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_volume: # select as "Volume set script"
|
||||
alias: Shield volume (speaker switch)
|
||||
sequence:
|
||||
- action: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.living_room_speaker
|
||||
data:
|
||||
volume_level: "{{ level }}"
|
||||
shield_source: # select as "Select source script"
|
||||
alias: Shield source (AVR)
|
||||
sequence:
|
||||
- action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.avr
|
||||
data:
|
||||
source: "{{ app_name }}"
|
||||
shield_mute: # select as "Mute script"
|
||||
alias: Shield mute (speaker switch)
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ is_volume_muted }}"
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
default:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
```
|
||||
|
||||
Then set `script.shield_volume` as **Volume set script**, `script.shield_source` as
|
||||
**Select source script** and so on on the Configure page. Scripts are set there, not
|
||||
in the YAML seed above — the seed only carries the address and the transport choice.
|
||||
|
||||
A script that fails surfaces as a translated error on the service call that triggered
|
||||
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 app-name resolution
|
||||
uses. With no map configured it falls back to a small built-in list
|
||||
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||
something to show. Discovery fills the **Configure** table, not this fallback: the
|
||||
dropdown stays exactly the map you saved, so what a card offers is always what you
|
||||
chose. An app that discovery found and you want in the dropdown is one row to add.
|
||||
|
||||
**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
|
||||
type: media-control
|
||||
entity: media_player.living_room_shield
|
||||
```
|
||||
|
||||
- **`source_list`** — the app names. They come from the map (in the order it lists
|
||||
them), so editing the map renames the dropdown entries.
|
||||
- **`source`** — the app currently on screen, resolved exactly like the Current App
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
- **`media_player.select_source`** — picks one **by name** and launches it over the
|
||||
protocol, the same path `play_media` uses for apps. A raw package id works too (that
|
||||
holds the package id), names match case-insensitively, and a name that
|
||||
is not in the list is an error rather than a silent no-op:
|
||||
|
||||
```yaml
|
||||
action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.living_room_shield
|
||||
data:
|
||||
source: TiviMate
|
||||
```
|
||||
|
||||
Selecting a source is sometimes more than launching an app — an AVR input, a speaker
|
||||
switch. That is what the **Select source script** above is for: with one configured it
|
||||
replaces the native launch entirely.
|
||||
|
||||
#### Known limits (the Apps map)
|
||||
|
||||
The map is your own override, so two shapes are accepted rather than papered over:
|
||||
|
||||
- **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: `media_player.select_source` accepts its own package id.
|
||||
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||
dropdown, but `app_name`, `source` and the Current App sensor
|
||||
display the map value **verbatim** — so `source` can be a string that is not in
|
||||
`source_list`. A blank value falls through to the raw package instead.
|
||||
|
||||
### Remote command script
|
||||
|
||||
`remote.send_command` sends protocol keys: repeats, long presses (`START_LONG`/
|
||||
`END_LONG`) and inter-repeat delays. Some setups need that call to go somewhere else —
|
||||
an IR bridge, a macro, a second device — so one optional **Remote command script**
|
||||
replaces the whole key path when it is set. It receives the service's own parameters,
|
||||
named as they are on the service:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `command` | the list of key commands as submitted, e.g. `["HOME", "KEYCODE_DPAD_CENTER"]` |
|
||||
| `num_repeats` | how many times to send them (default `1`) |
|
||||
| `delay_secs` | the delay between repeats in seconds (default `0.0`) |
|
||||
| `hold_secs` | the requested long-press duration (default `0.0`) — the script owns the key semantics, so it decides what "hold" means |
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_remote: # select as "Remote command script"
|
||||
alias: Shield remote (IR bridge)
|
||||
sequence:
|
||||
- action: remote.send_command
|
||||
target:
|
||||
entity_id: remote.living_room_bridge
|
||||
data:
|
||||
command: "{{ command }}" # a list: pass it through
|
||||
num_repeats: "{{ num_repeats }}"
|
||||
delay_secs: "{{ delay_secs }}"
|
||||
hold_secs: "{{ hold_secs }}"
|
||||
```
|
||||
|
||||
All four variables are always present, including their defaults, so a script can rely
|
||||
on the shape. Unset — the default — nothing changes: keys go out over the protocol
|
||||
exactly as before.
|
||||
|
||||
## Entities
|
||||
|
||||
|
||||
@@ -25,14 +25,22 @@ from .const import (
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SERVICE_ADB_COMMAND,
|
||||
)
|
||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||
from .helpers import (
|
||||
entry_host,
|
||||
protocol_cert_paths,
|
||||
resolve_adb_config,
|
||||
seed_adb_enabled,
|
||||
seed_host,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,9 +58,9 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""
|
||||
domain_config = (config or {}).get(DOMAIN)
|
||||
for block in _yaml_seed_blocks(domain_config or []):
|
||||
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST):
|
||||
if not seed_host(block):
|
||||
_LOGGER.warning(
|
||||
"nvidia_shield YAML block without protocol/adb host ignored: %s",
|
||||
"nvidia_shield YAML block without an address ignored: %s",
|
||||
block.get("name", "<unnamed>"),
|
||||
)
|
||||
continue
|
||||
@@ -84,15 +92,13 @@ def _yaml_seed_blocks(domain_config) -> list[dict]:
|
||||
continue
|
||||
name = block.get("name") or DEFAULT_NAME
|
||||
# t32 F4 (review rider): the seed honors the block's shape — a
|
||||
# protocol-only block does NOT force ADB on; ADB comes only when the
|
||||
# block carries an adb host (interactive cosmetics stay in the flow).
|
||||
# protocol-only block does NOT force ADB on. t41: the address is ONE
|
||||
# field (`host`, or a legacy protocol_host/adb_host).
|
||||
seed = {
|
||||
"name": name,
|
||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
|
||||
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||
CONF_HOST: seed_host(block),
|
||||
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||
# protocol fields are prefill hints ONLY — pairing stays interactive
|
||||
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
|
||||
}
|
||||
if block.get("unique_id"):
|
||||
seed["unique_id"] = str(block["unique_id"])
|
||||
@@ -178,15 +184,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||
|
||||
Falls back to the androidtv-remote host discovery (the ADB host fallback)
|
||||
until the user pairs, after which the explicit protocol host persists.
|
||||
The address is the entry's own single field (t41). Nothing is derived from
|
||||
another integration.
|
||||
"""
|
||||
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||
return None
|
||||
|
||||
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
_LOGGER.info(
|
||||
"Protocol enabled for entry %s but no host resolvable — continuing "
|
||||
@@ -205,23 +209,62 @@ async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate v1 entries to v2 (adds the remote-protocol fields).
|
||||
"""Migrate an older config entry to the current version.
|
||||
|
||||
ADB options carry over untouched; the ADB-host discovery fallback keeps
|
||||
serving as the protocol host until pairing populates it. Downgrades
|
||||
(version > 2) are rejected so HA offers a rollback.
|
||||
v1 → v2 added the remote-protocol fields (ADB options carried over
|
||||
untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
|
||||
|
||||
* the duplicated address fields collapse into the single CONF_HOST — the
|
||||
protocol host wins, else the ADB host, since both name the same Shield;
|
||||
* the androidtv-entity key goes. This integration owns the whole stack now:
|
||||
the entity it used to configure is neither a state source nor an app-name
|
||||
or power tier, so keeping the value would only mislead.
|
||||
|
||||
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
|
||||
"""
|
||||
if config_entry.version > 2:
|
||||
if config_entry.version > ENTRY_VERSION:
|
||||
return False
|
||||
if config_entry.version >= ENTRY_VERSION:
|
||||
return True
|
||||
|
||||
options = {**config_entry.options}
|
||||
data = {**config_entry.data}
|
||||
|
||||
if config_entry.version < 2:
|
||||
options = {**config_entry.options}
|
||||
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
||||
options.setdefault(CONF_PROTOCOL_HOST, "")
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, version=2
|
||||
|
||||
if not options.get(CONF_HOST):
|
||||
options[CONF_HOST] = (
|
||||
options.get(CONF_PROTOCOL_HOST)
|
||||
or options.get(CONF_ADB_HOST)
|
||||
or data.get(CONF_PROTOCOL_HOST)
|
||||
or data.get(CONF_ADB_HOST)
|
||||
or ""
|
||||
)
|
||||
|
||||
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
|
||||
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
|
||||
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
options.pop(legacy_key, None)
|
||||
data.pop(legacy_key, None)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=data, options=options, version=ENTRY_VERSION
|
||||
)
|
||||
if dropped_entity:
|
||||
_LOGGER.info(
|
||||
"Migrated %s config entry to version 2", config_entry.title
|
||||
"%s: config entry migrated to version %d — dropped the androidtv "
|
||||
"entity %s (this integration reads the Shield directly; the entry "
|
||||
"needs no external media_player)",
|
||||
config_entry.title,
|
||||
ENTRY_VERSION,
|
||||
dropped_entity,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Migrated %s config entry to version %d",
|
||||
config_entry.title,
|
||||
ENTRY_VERSION,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -11,13 +11,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import discover_adb_host
|
||||
from .helpers import entry_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,9 +53,9 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
# t41: the entry's own single address — never derived from another
|
||||
# integration's entity.
|
||||
host = entry_host(self._entry)
|
||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||
|
||||
if not host:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
self._app_label_requests.add(package)
|
||||
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()
|
||||
|
||||
@@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
|
||||
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
|
||||
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
|
||||
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
|
||||
# runtime code reads them.
|
||||
CONF_HOST = "host"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_PROTOCOL_HOST = "protocol_host"
|
||||
|
||||
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
|
||||
# path it configured is gone), never read at runtime.
|
||||
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
|
||||
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
||||
CONF_RECEIVER_ENTITY = "receiver_entity"
|
||||
@@ -19,12 +29,10 @@ CONF_RECEIVER_INPUT = "receiver_input"
|
||||
CONF_POWER_ENTITY = "power_entity"
|
||||
CONF_ADB_ENABLED = "adb_enabled"
|
||||
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_ADB_PORT = "adb_port"
|
||||
|
||||
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||
CONF_PROTOCOL_HOST = "protocol_host"
|
||||
CONF_PAIRING_CODE = "pairing_code"
|
||||
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
|
||||
# steps — the entry still finishes (passive mode) instead of stranding the
|
||||
@@ -47,6 +55,11 @@ STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||
|
||||
DEFAULT_NAME = "Nvidia Shield"
|
||||
|
||||
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
|
||||
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
|
||||
# androidtv-entity key. One constant so the flow and the migration cannot drift.
|
||||
ENTRY_VERSION = 3
|
||||
|
||||
# Emotiva DAG attribute we read to get available inputs
|
||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||
|
||||
@@ -108,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"
|
||||
@@ -131,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"
|
||||
@@ -147,3 +206,72 @@ SYSTEM_MEDIA_PACKAGES = [
|
||||
"com.android.tv.settings",
|
||||
"com.nvidia.diagtools",
|
||||
]
|
||||
|
||||
|
||||
# t44: per-command script overrides — the escape hatch for hardware this
|
||||
# integration deliberately does not drive itself (a speaker switch that owns the
|
||||
# volume, an AVR input, a mute that has to hit two devices). One script per
|
||||
# command: when one is configured, that command calls the script INSTEAD of the
|
||||
# native protocol/ADB path, and with none configured nothing changes at all.
|
||||
CONF_COMMAND_SCRIPTS = "command_scripts"
|
||||
SCRIPT_COMMANDS = (
|
||||
"turn_on",
|
||||
"turn_off",
|
||||
"volume_up",
|
||||
"volume_down",
|
||||
"volume_set",
|
||||
"volume_mute",
|
||||
"select_source",
|
||||
)
|
||||
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
|
||||
SCRIPT_FIELD_PREFIX = "script_"
|
||||
#: Variables a command hands its script (HA script variables). volume_mute passes
|
||||
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
|
||||
SCRIPT_VAR_LEVEL = "level"
|
||||
SCRIPT_VAR_APP_NAME = "app_name"
|
||||
SCRIPT_VAR_MUTED = "is_volume_muted"
|
||||
|
||||
|
||||
def command_scripts(options) -> dict[str, str]:
|
||||
"""The script configured per command, normalized (t44).
|
||||
|
||||
The ONE place the stored option's shape is read: the options flow, the media
|
||||
player and the remote entity all go through it. Options round-trip through HA
|
||||
storage and can be hand-edited, so a value that is not a non-empty string for a
|
||||
known command is dropped rather than handed to the service layer.
|
||||
"""
|
||||
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
scripts: dict[str, str] = {}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
script = raw.get(command)
|
||||
if isinstance(script, str) and script.strip():
|
||||
scripts[command] = script.strip()
|
||||
return scripts
|
||||
|
||||
|
||||
# t47: ONE optional script for the remote entity's send_command. It replaces the
|
||||
# whole key path (repeats, hold, delays included) rather than per-key overrides,
|
||||
# because a remote command is a sequence: an IR bridge, a macro or a second device
|
||||
# needs the call, not individual keys.
|
||||
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
|
||||
#: Variables remote.send_command hands its script — the names are the service's own
|
||||
#: parameter names, and they are documented in the README and pinned in tests.
|
||||
SCRIPT_VAR_COMMAND = "command"
|
||||
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
|
||||
SCRIPT_VAR_DELAY_SECS = "delay_secs"
|
||||
SCRIPT_VAR_HOLD_SECS = "hold_secs"
|
||||
|
||||
|
||||
def remote_command_script(options) -> str | None:
|
||||
"""The script configured for remote.send_command, or None (t47).
|
||||
|
||||
Same defensive read as ``command_scripts``: options round-trip through HA
|
||||
storage and can be hand-edited, so anything that is not a non-empty string is
|
||||
"not configured" rather than a service call to nowhere.
|
||||
"""
|
||||
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||
if isinstance(script, str) and script.strip():
|
||||
return script.strip()
|
||||
return None
|
||||
@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.redact import async_redact_data
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .media_player import entity_available
|
||||
|
||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||
_REDACTED = "**REDACTED**"
|
||||
@@ -81,7 +82,14 @@ async def async_get_config_entry_diagnostics(
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
payload["store"] = manager.store_snapshot()
|
||||
payload["transport"] = {
|
||||
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||
# t40: the ladder's verdict, from the same helper the entities use,
|
||||
# so a support dump answers "why is my entity unavailable?".
|
||||
"entity_available": entity_available(manager),
|
||||
"adb": {
|
||||
"configured": manager.available,
|
||||
"connected": manager.connected,
|
||||
"awake": manager.awake,
|
||||
},
|
||||
"protocol": {
|
||||
"active": manager.protocol_active,
|
||||
"available": manager.is_available,
|
||||
|
||||
@@ -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
|
||||
return 2
|
||||
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:
|
||||
|
||||
@@ -4,12 +4,13 @@ import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -42,31 +43,51 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
|
||||
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||
|
||||
|
||||
def discover_adb_host(
|
||||
hass: HomeAssistant, androidtv_entity_id: str | None
|
||||
) -> str | None:
|
||||
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
|
||||
if not androidtv_entity_id:
|
||||
return None
|
||||
registry = er.async_get(hass)
|
||||
entity_entry = registry.async_get(androidtv_entity_id)
|
||||
if not entity_entry or not entity_entry.config_entry_id:
|
||||
return None
|
||||
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||
if config_entry:
|
||||
return config_entry.data.get("host")
|
||||
return None
|
||||
def entry_host(entry) -> str:
|
||||
"""The Shield's address for an entry (t41: ONE field).
|
||||
|
||||
``options`` is the source of truth; ``data`` is read as a fallback so an
|
||||
entry created by an older flow (or a hand-written seed) still resolves.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip()
|
||||
|
||||
|
||||
def seed_host(block: dict) -> str:
|
||||
"""The address a YAML seed block carries (t41).
|
||||
|
||||
``host`` is the current key; the legacy ``protocol_host`` / ``adb_host``
|
||||
stay accepted so an existing configuration.yaml keeps seeding, protocol
|
||||
first (that transport is the one that cannot fall back to anything else).
|
||||
"""
|
||||
for key in (CONF_HOST, CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
value = str((block or {}).get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def seed_adb_enabled(block: dict) -> bool:
|
||||
"""Does a seed block ask for ADB? (t32 F4's shape rule, t41's keys).
|
||||
|
||||
An explicit ``adb_enabled`` wins. Otherwise an address that names the Shield
|
||||
— the current ``host`` key or the legacy ``adb_host`` — turns ADB on, while
|
||||
a protocol-only block leaves it off.
|
||||
"""
|
||||
block = block or {}
|
||||
if CONF_ADB_ENABLED in block:
|
||||
return bool(block[CONF_ADB_ENABLED])
|
||||
return bool(block.get(CONF_HOST) or block.get(CONF_ADB_HOST))
|
||||
|
||||
|
||||
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||
|
||||
Priority: explicit ADB host/port options > host from the AndroidTV
|
||||
entity's own config entry.
|
||||
Priority: the entry's own address (t41: no other integration is consulted)
|
||||
and its ADB port option.
|
||||
"""
|
||||
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
return None
|
||||
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "2026.10.08",
|
||||
"version": "2026.10.12",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,7 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
remote_command_script,
|
||||
)
|
||||
from .media_player import async_call_script, async_run_command_script, entity_available
|
||||
|
||||
_START_PREFIX = "START_LONG:"
|
||||
_END_PREFIX = "END_LONG:"
|
||||
@@ -93,17 +102,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Unavailable while the transport is active but unpaired/down.
|
||||
"""Available while ANY usable transport can serve a command (t40).
|
||||
|
||||
A passive manager (no protocol transport) keeps the entity available:
|
||||
nothing to transport is not a broken one.
|
||||
The same ladder as the media player (``entity_available``): protocol
|
||||
wired+available, ADB connected, fresh wake data or a passive manager all
|
||||
count as usable, and a passive manager stays available — nothing to
|
||||
transport is not a broken one.
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return True
|
||||
if not manager.protocol_active:
|
||||
return True
|
||||
return manager.is_available is not False
|
||||
return entity_available(self._manager_of())
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
@@ -133,7 +139,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
self._manager_unregistered = None
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Wake the device with the POWER key when it reports off."""
|
||||
"""Wake the device — via the configured script, else the POWER key."""
|
||||
# t44: `turn_on` is one configuration, and the remote entity is the other
|
||||
# route to it; a script that owns the power sequence owns it here too.
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
@@ -143,7 +153,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Sleep the device with the POWER key when it reports on."""
|
||||
"""Sleep the device — via the configured script, else the POWER key."""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
@@ -158,7 +170,29 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
delay_secs: float = 0.0,
|
||||
hold_secs: float = 0.0,
|
||||
) -> None:
|
||||
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG)."""
|
||||
"""Send protocol keys — or hand the whole command to the configured script.
|
||||
|
||||
t47: with a remote command script configured, it replaces the key path
|
||||
entirely and receives the service call's own parameters, named as they are
|
||||
on the service: ``command`` (a list of key commands), ``num_repeats``,
|
||||
``delay_secs`` and ``hold_secs``. Unset, nothing changes: repeats, long
|
||||
presses and delays go out as protocol keys exactly as before.
|
||||
"""
|
||||
script = remote_command_script(self._entry.options)
|
||||
if script:
|
||||
await async_call_script(
|
||||
self.hass,
|
||||
script,
|
||||
"remote.send_command",
|
||||
{
|
||||
SCRIPT_VAR_COMMAND: command,
|
||||
SCRIPT_VAR_NUM_REPEATS: num_repeats,
|
||||
SCRIPT_VAR_DELAY_SECS: delay_secs,
|
||||
SCRIPT_VAR_HOLD_SECS: hold_secs,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
|
||||
@@ -12,10 +12,12 @@ from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .media_player import resolve_app_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,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))
|
||||
@@ -40,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
|
||||
@@ -65,9 +73,9 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
"""Current running app on the Shield.
|
||||
|
||||
Primary source: the androidtv entity's own state. When that entity is
|
||||
unavailable, falls back to the ShieldConnectionManager's StateStore
|
||||
(most recent visible ADB recent-task app).
|
||||
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
||||
push, or the most recent visible ADB recent-task app when that has not
|
||||
reported (t41: our own transports only).
|
||||
"""
|
||||
|
||||
_attr_translation_key = "current_app"
|
||||
@@ -75,7 +83,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||
|
||||
def _manager(self) -> ShieldConnectionManager | None:
|
||||
@@ -83,56 +90,168 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self._unsub_listeners.append(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
||||
)
|
||||
)
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
# Refresh when the connection manager updates its store.
|
||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||
|
||||
@callback
|
||||
def _handle_state_change(self, event: Event) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_store_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if state:
|
||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||
def _package(self) -> str | None:
|
||||
"""The raw package of the app that is on screen (t41, t55).
|
||||
|
||||
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
||||
# protocol current_app push → ADB mResumedActivity → ADB recents
|
||||
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
||||
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 not None:
|
||||
foreground = manager.foreground_app
|
||||
if foreground:
|
||||
return foreground
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return running[0].get("package")
|
||||
if manager is None:
|
||||
return None
|
||||
foreground = manager.foreground_app
|
||||
if foreground:
|
||||
return foreground
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return running[0].get("package")
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""The resolved friendly name (t40), not the raw package id.
|
||||
|
||||
Exactly the ``media_player.app_name`` chain via the shared
|
||||
``resolve_app_name``: apps map → cached manifest label → the package
|
||||
text itself. A bare or unknown package shows its package text rather
|
||||
than None, like the media player's app_name.
|
||||
|
||||
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(
|
||||
self._package(),
|
||||
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||
manager.app_label if manager is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if state:
|
||||
return {
|
||||
"app_id": state.attributes.get("app_id"),
|
||||
"app_name": state.attributes.get("app_name"),
|
||||
}
|
||||
"""The raw package stays visible here (t40) next to the shown name."""
|
||||
package = self._package()
|
||||
attrs: dict = {}
|
||||
if package:
|
||||
attrs["app_id"] = package
|
||||
name = self.native_value
|
||||
if name:
|
||||
attrs["app_name"] = name
|
||||
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:
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return {"app_id": running[0].get("package")}
|
||||
return {}
|
||||
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):
|
||||
|
||||
@@ -3,76 +3,124 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, 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",
|
||||
"androidtv_entity": "Android TV Remote entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"protocol_host": "Protocol host (IP address)"
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||
"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).",
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \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.",
|
||||
"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.",
|
||||
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||
"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": {
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"adb_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",
|
||||
"protocol_host": "Protocol host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"skip_pairing": "Continue without protocol pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_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": {
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"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.",
|
||||
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, '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.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
"abort": {
|
||||
@@ -80,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": {},
|
||||
@@ -94,36 +142,124 @@
|
||||
"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",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"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": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"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).",
|
||||
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||
"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 — 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,15 +283,23 @@
|
||||
"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 — the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -165,6 +309,9 @@
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,76 +3,124 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, 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",
|
||||
"androidtv_entity": "Android TV Remote entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"protocol_host": "Protocol host (IP address)"
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||
"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).",
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \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.",
|
||||
"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.",
|
||||
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||
"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": {
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"adb_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",
|
||||
"protocol_host": "Protocol host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"skip_pairing": "Continue without protocol pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_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": {
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"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.",
|
||||
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, '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.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
"abort": {
|
||||
@@ -80,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": {},
|
||||
@@ -94,36 +142,124 @@
|
||||
"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",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"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": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"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).",
|
||||
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||
"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 — 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,15 +283,23 @@
|
||||
"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 — the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -165,6 +309,9 @@
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Resolve the version this push should release (t42).
|
||||
#
|
||||
# Why a script: this decision is what makes a release visible to HACS, and every
|
||||
# wrong answer is silent — the tag and the release still exist, only the manifest
|
||||
# never moves. So the logic lives here, where tests/test_release_versioning.py
|
||||
# drives every branch locally, instead of inline in a workflow nobody can run.
|
||||
#
|
||||
# Resolution (M = manifest version, D = today in UTC, tags = existing v-tags):
|
||||
# 1. M is tagged AND that tag is the commit being released
|
||||
# -> RELEASE=no (SOURCE=released): a re-run, this commit is already out
|
||||
# 2. M is not tagged -> VERSION=M (SOURCE=manifest)
|
||||
# an unreleased manifest version wins: bump it to force a release
|
||||
# 3. D is not tagged -> VERSION=D (SOURCE=date)
|
||||
# the one automatic release per UTC day
|
||||
# 4. otherwise -> VERSION=D.N (SOURCE=same-day)
|
||||
# N is the LOWEST untagged N >= 2, so further releases in the same UTC day
|
||||
# become 2026.10.11.2, 2026.10.11.3, … instead of silently doing nothing
|
||||
#
|
||||
# The workflow writes the resolved VERSION back into the manifest whenever it
|
||||
# differs, then tags THAT commit, so the released artifact and its tag always
|
||||
# agree. The defect this fixes: v2026.10.09 shipped while master's manifest still
|
||||
# said 2026.10.08, leaving HACS comparing against a version that never moved.
|
||||
#
|
||||
# Usage: scripts/determine-release-version.sh [--manifest PATH] [--today YYYY.MM.DD]
|
||||
# [--tags-from FILE] [--head SHA]
|
||||
#
|
||||
# --manifest manifest to read (default: custom_components/nvidia_shield/manifest.json)
|
||||
# --today override the UTC date — for tests and dry runs only
|
||||
# --tags-from read "TAG [SHA]" lines instead of asking git — for tests and dry runs
|
||||
# --head the commit being released (default: git rev-parse HEAD)
|
||||
#
|
||||
# Stdout, and $GITHUB_OUTPUT as well when it is set (which is what the workflow
|
||||
# consumes): VERSION, TAG, SOURCE, BUMP, RELEASE. Commentary goes to stderr.
|
||||
# RELEASE=no is the explicit no-op signal: nothing may be tagged or published.
|
||||
# Exit status is 1 only for a usage or manifest error, so "no release" is not a
|
||||
# failed job.
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="custom_components/nvidia_shield/manifest.json"
|
||||
TAGS_FROM=""
|
||||
HEAD_SHA=""
|
||||
TODAY=""
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--manifest) MANIFEST="${2:?--manifest needs a path}"; shift 2 ;;
|
||||
--today) TODAY="${2:?--today needs YYYY.MM.DD}"; shift 2 ;;
|
||||
--tags-from) TAGS_FROM="${2:?--tags-from needs a path}"; shift 2 ;;
|
||||
--head) HEAD_SHA="${2:?--head needs a sha}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
note() { printf '%s\n' "$*" >&2; }
|
||||
|
||||
[[ -f "$MANIFEST" ]] || { note "manifest not found: $MANIFEST"; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { note "jq is required to read the manifest"; exit 1; }
|
||||
|
||||
MANIFEST_VERSION="$(jq -r '.version // empty' "$MANIFEST")"
|
||||
[[ -n "$MANIFEST_VERSION" ]] || { note "no .version in $MANIFEST"; exit 1; }
|
||||
# Tolerate a hand-written leading "v" (the tag adds its own); anything else that
|
||||
# is not YYYY.MM.DD[.N] is a mistake worth failing for.
|
||||
MANIFEST_VERSION="${MANIFEST_VERSION#v}"
|
||||
[[ "$MANIFEST_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || {
|
||||
note "manifest version '$MANIFEST_VERSION' is not YYYY.MM.DD[.N]"; exit 1; }
|
||||
|
||||
if [[ -n "$TODAY" ]]; then
|
||||
[[ "$TODAY" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || { note "--today must be YYYY.MM.DD"; exit 1; }
|
||||
DATE_VERSION="$TODAY"
|
||||
else
|
||||
# UTC, always: a runner's local timezone must never decide a version number.
|
||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||
fi
|
||||
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
[[ -f "$TAGS_FROM" ]] || { note "tag list not found: $TAGS_FROM"; exit 1; }
|
||||
else
|
||||
command -v git >/dev/null 2>&1 || { note "git is required to list tags"; exit 1; }
|
||||
fi
|
||||
|
||||
tag_exists() {
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
awk -v wanted="$1" '$1 == wanted { found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||
else
|
||||
git rev-parse -q --verify "refs/tags/$1" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
tag_sha() {
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
awk -v wanted="$1" '$1 == wanted && NF > 1 { print $2; found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||
else
|
||||
git rev-parse -q --verify "refs/tags/$1^{commit}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$HEAD_SHA" ]] && [[ -z "$TAGS_FROM" ]]; then
|
||||
HEAD_SHA="$(git rev-parse -q --verify HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
VERSION=""
|
||||
SOURCE=""
|
||||
RELEASE="yes"
|
||||
|
||||
if tag_exists "v${MANIFEST_VERSION}" && [[ -n "$HEAD_SHA" ]] \
|
||||
&& [[ "$(tag_sha "v${MANIFEST_VERSION}" 2>/dev/null || true)" == "$HEAD_SHA" ]]; then
|
||||
VERSION="$MANIFEST_VERSION"; SOURCE="released"; RELEASE="no"
|
||||
note "v${VERSION} is already tagged on this commit — nothing to release"
|
||||
elif ! tag_exists "v${MANIFEST_VERSION}"; then
|
||||
VERSION="$MANIFEST_VERSION"; SOURCE="manifest"
|
||||
elif ! tag_exists "v${DATE_VERSION}"; then
|
||||
VERSION="$DATE_VERSION"; SOURCE="date"
|
||||
else
|
||||
N=2
|
||||
while tag_exists "v${DATE_VERSION}.${N}"; do N=$((N + 1)); done
|
||||
VERSION="${DATE_VERSION}.${N}"; SOURCE="same-day"
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "yes" ]] && tag_exists "v${VERSION}"; then
|
||||
# Backstop, not a normal path: the resolution above only ever picks an
|
||||
# untagged version, so reaching this means the tag set changed underneath us
|
||||
# (a race with a parallel run — the concurrency group is the primary guard).
|
||||
# Never tag or publish something that already exists.
|
||||
RELEASE="no"; SOURCE="tagged"
|
||||
note "v${VERSION} already exists — nothing to release"
|
||||
fi
|
||||
|
||||
BUMP="no"
|
||||
if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then
|
||||
BUMP="yes"
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "yes" ]]; then
|
||||
note "releasing v${VERSION} (${SOURCE}); manifest says ${MANIFEST_VERSION}, bump=${BUMP}"
|
||||
else
|
||||
note "no release (${SOURCE})"
|
||||
fi
|
||||
|
||||
emit() {
|
||||
printf '%s=%s\n' "$1" "$2"
|
||||
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
emit VERSION "$VERSION"
|
||||
emit TAG "v${VERSION}"
|
||||
emit SOURCE "$SOURCE"
|
||||
emit BUMP "$BUMP"
|
||||
emit RELEASE "$RELEASE"
|
||||
@@ -50,3 +50,4 @@ _load_module(
|
||||
# Stdlib-only integration modules.
|
||||
for _mod in ("const", "adb_session", "connection"):
|
||||
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
||||
|
||||
+162
-5
@@ -42,12 +42,54 @@ ENTITIES_STUB_NAMES = (
|
||||
)
|
||||
|
||||
|
||||
class EntitySelectorStub:
|
||||
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
||||
|
||||
Real HA answers a cleared entity field with "Schema validation failed at …"
|
||||
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
|
||||
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
|
||||
values to prove it.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
raise ValueError("Entity is not a valid entity id")
|
||||
return data
|
||||
|
||||
|
||||
def install_selector_faces(selector_module) -> None:
|
||||
"""Populate a ``homeassistant.helpers.selector`` stub module.
|
||||
|
||||
One definition for every installer (entity_stublib and the four per-file
|
||||
installers in the flow tests), so the stubs cannot drift apart.
|
||||
"""
|
||||
selector_module.EntitySelector = EntitySelectorStub
|
||||
|
||||
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 = _Config
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
|
||||
|
||||
class MediaPlayerState(enum.StrEnum):
|
||||
OFF = "off"
|
||||
ON = "on"
|
||||
IDLE = "idle"
|
||||
PLAYING = "playing"
|
||||
PAUSED = "paused"
|
||||
STANDBY = "standby"
|
||||
BUFFERING = "buffering"
|
||||
|
||||
|
||||
class MediaPlayerEntityFeature(enum.IntFlag):
|
||||
@@ -63,6 +105,7 @@ class MediaPlayerEntityFeature(enum.IntFlag):
|
||||
PLAY_MEDIA = 256
|
||||
NEXT_TRACK = 512
|
||||
PREVIOUS_TRACK = 1024
|
||||
SELECT_SOURCE = 2048
|
||||
|
||||
|
||||
class MediaClass:
|
||||
@@ -172,9 +215,30 @@ class RemoteEntity:
|
||||
return None
|
||||
|
||||
|
||||
#: Every face install() registers. The guard below checks the WHOLE set, not one
|
||||
#: sentinel: test files scope their stubs per file (snapshot + pop at teardown, the
|
||||
#: t7 discipline), so one file can leave `homeassistant.components.media_player`
|
||||
#: behind while another name it popped — e.g. `voluptuous` — is gone. A sentinel
|
||||
#: guard then made install() a no-op in the next file, and whichever module needed
|
||||
#: the missing face failed with ModuleNotFoundError depending on file order (t44).
|
||||
#: The faces install() provides, derived from the list every test file scopes on.
|
||||
#: The ``nsh.*`` entries there are the integration modules the harness loads, not
|
||||
#: stub faces, so they are filtered out here.
|
||||
_STUB_FACES = tuple(
|
||||
name for name in ENTITIES_STUB_NAMES if not name.startswith("nsh.")
|
||||
)
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Register the stub modules (idempotent)."""
|
||||
if "homeassistant.components.media_player" in sys.modules:
|
||||
"""Register the stub modules (idempotent, and self-healing).
|
||||
|
||||
Callers must be able to rely on this regardless of what a previous test file
|
||||
popped: a missing face rebuilds the whole set, so any subset or file order
|
||||
works. A complete set is left alone — a file that installed richer faces of
|
||||
its own (tests/test_config_flow_ux.py's voluptuous mirror, the discovery
|
||||
stub) keeps them.
|
||||
"""
|
||||
if all(name in sys.modules for name in _STUB_FACES):
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
@@ -307,12 +371,19 @@ def install() -> None:
|
||||
|
||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||
|
||||
class ServiceValidationErrorStubError(Exception):
|
||||
class HomeAssistantErrorStubError(Exception):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||
self.translation_domain = kwargs.get("translation_domain")
|
||||
self.translation_key = kwargs.get("translation_key")
|
||||
self.translation_placeholders = kwargs.get("translation_placeholders")
|
||||
|
||||
class ServiceValidationErrorStubError(HomeAssistantErrorStubError):
|
||||
"""Real HA: ServiceValidationError derives from HomeAssistantError (t44)."""
|
||||
|
||||
# t44: media_player raises HomeAssistantError when a configured command script
|
||||
# fails, so both names have to exist — and with the real inheritance.
|
||||
exceptions.HomeAssistantError = HomeAssistantErrorStubError
|
||||
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||
sys.modules["homeassistant.exceptions"] = exceptions
|
||||
|
||||
@@ -327,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",
|
||||
@@ -400,7 +475,7 @@ def install() -> None:
|
||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||
helpers.event = event
|
||||
helpers.selector = types.SimpleNamespace(
|
||||
EntitySelector=lambda *a, **k: None,
|
||||
EntitySelector=EntitySelectorStub,
|
||||
EntitySelectorConfig=lambda *a, **k: "",
|
||||
SelectSelector=lambda *a, **k: None,
|
||||
SelectSelectorConfig=lambda **k: "",
|
||||
@@ -455,9 +530,51 @@ def install() -> None:
|
||||
sys.modules["homeassistant.helpers.event"] = event
|
||||
sys.modules["homeassistant.helpers.entity"] = entity
|
||||
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||
|
||||
# t44: config_flow imports `selector` from this package, and the per-file stub
|
||||
# installers register it as its own face. Installing it here too keeps
|
||||
# `_STUB_FACES` honest: install() then really does provide every face a test
|
||||
# file may have popped, which is what its guard checks.
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
install_selector_faces(selector)
|
||||
selector.SelectSelector = lambda *args, **kwargs: None
|
||||
selector.SelectSelectorConfig = lambda **kwargs: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
helpers.selector = selector
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
sys.modules["homeassistant.helpers"] = helpers
|
||||
|
||||
|
||||
#: Namespaces the stub harness owns. Anything under these prefixes that a test
|
||||
#: file creates is harness state, so a file's teardown must remove it: a module
|
||||
#: left behind keeps the stub objects it captured (an exception class, the redact
|
||||
#: helper the diagnostics module holds) after the next file rebuilt them, which is
|
||||
#: how a file-order-dependent failure looked (t44).
|
||||
_TRACKED_PREFIXES = ("homeassistant", "voluptuous", "nsh")
|
||||
|
||||
|
||||
def _tracked(name: str) -> bool:
|
||||
return name.split(".", 1)[0] in _TRACKED_PREFIXES
|
||||
|
||||
|
||||
def snapshot_modules() -> dict:
|
||||
"""Every stub/module name live right now — for a leak-free teardown."""
|
||||
return {name: module for name, module in sys.modules.items() if _tracked(name)}
|
||||
|
||||
|
||||
def restore_modules(snapshot: dict) -> None:
|
||||
"""Undo everything the current test file did to those namespaces.
|
||||
|
||||
Two halves, and the first is what a hand-written list of names kept getting
|
||||
wrong: drop every tracked name the file created (so the next file loads it
|
||||
fresh, against ITS stubs), then put back every object it replaced.
|
||||
"""
|
||||
for name in [name for name in sys.modules if _tracked(name) and name not in snapshot]:
|
||||
del sys.modules[name]
|
||||
sys.modules.update(snapshot)
|
||||
|
||||
|
||||
def load_entity_modules() -> dict:
|
||||
"""Load the integration's HA-free-ish entity modules under nsh."""
|
||||
install()
|
||||
@@ -471,6 +588,11 @@ def load_entity_modules() -> dict:
|
||||
"media_player",
|
||||
"remote",
|
||||
"diagnostics",
|
||||
# t41: button.py was the file nobody loaded — a deleted helper it still
|
||||
# imported shipped silently. It is a platform like the others, so it
|
||||
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
|
||||
# is what keeps this list from having to be complete.
|
||||
"button",
|
||||
"init",
|
||||
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||
if name == "init" and "nsh.init" in sys.modules:
|
||||
@@ -491,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)
|
||||
|
||||
+526
-9
@@ -30,7 +30,31 @@ dependency against an INSTALLED homeassistant:
|
||||
must exist in the real signature — the ``options_updates`` class of bug;
|
||||
3. every ``self.<method>()`` call inside a class that inherits from an HA class
|
||||
is compared against the real definition: a coroutine must be awaited, and an
|
||||
awaited call must actually be a coroutine.
|
||||
awaited call must actually be a coroutine;
|
||||
4. every class inheriting from an HA entity base has its HA-READ attributes
|
||||
(``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``'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
|
||||
resolve to a file of this package that defines ``X`` (a submodule counts).
|
||||
``button.py`` imported a helper that a refactor had deleted, and because no
|
||||
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()``.
|
||||
|
||||
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 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
|
||||
@@ -79,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",
|
||||
@@ -95,14 +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] = {
|
||||
"modules": 10,
|
||||
# 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,
|
||||
"call_args": 20,
|
||||
"relative_imports": 100,
|
||||
# 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
|
||||
@@ -136,9 +182,10 @@ class Report:
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
modules: int = 0
|
||||
imports: int = 0
|
||||
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.
|
||||
|
||||
@@ -157,6 +204,8 @@ class Report:
|
||||
return (
|
||||
"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)"
|
||||
)
|
||||
@@ -166,6 +215,19 @@ class Report:
|
||||
# --------------------------------------------------------------------- imports
|
||||
|
||||
|
||||
def _iter_python_files(root: Path) -> list[Path]:
|
||||
"""Every Python file under a target, recursively (t41).
|
||||
|
||||
The sweep used to glob one level, so a platform or helper one directory down
|
||||
was invisible to both passes. Exhaustive-by-construction beats a list.
|
||||
"""
|
||||
if not root.is_dir():
|
||||
return [root]
|
||||
return sorted(
|
||||
path for path in root.rglob("*.py") if "__pycache__" not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def _check_import(module_name: str, attr: str | None) -> str | None:
|
||||
"""Return a reason string when the import target is missing, else None."""
|
||||
try:
|
||||
@@ -223,6 +285,206 @@ def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- the package's own imports
|
||||
#
|
||||
# Pure AST, no Home Assistant required — the same contract as the entity pass, so
|
||||
# the fast suite can prove it even in the androidtvremote2-less variant.
|
||||
|
||||
|
||||
def _package_root(path: Path) -> Path:
|
||||
"""The integration package a file belongs to.
|
||||
|
||||
The topmost ancestor directory that still has an ``__init__.py``: that is
|
||||
``custom_components/nvidia_shield`` for both its top-level modules and the
|
||||
vendored ``ha_adb_common`` subpackage, and it holds for a copied tree too
|
||||
(tests scan temp copies).
|
||||
"""
|
||||
root = path.parent
|
||||
while (root.parent / "__init__.py").exists():
|
||||
root = root.parent
|
||||
return root
|
||||
|
||||
|
||||
class _ModuleLevelNames(ast.NodeVisitor):
|
||||
"""Names a module binds at import time.
|
||||
|
||||
Functions, classes, assignments and imports — including the ones inside
|
||||
``if``/``try`` guards (the optional androidtvremote2 import lives in one).
|
||||
Function and class BODIES are skipped: a name bound at call time is not
|
||||
importable, which is exactly the distinction this pass needs.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.names: set[str] = set()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_ClassDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
self.names.add(alias.asname or alias.name.split(".")[0])
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
for alias in node.names:
|
||||
if alias.name != "*":
|
||||
self.names.add(alias.asname or alias.name)
|
||||
|
||||
def _bind(self, target: ast.AST) -> None:
|
||||
if isinstance(target, ast.Name):
|
||||
self.names.add(target.id)
|
||||
elif isinstance(target, (ast.Tuple, ast.List)):
|
||||
for element in target.elts:
|
||||
self._bind(element)
|
||||
elif isinstance(target, ast.Starred):
|
||||
self._bind(target.value)
|
||||
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
for target in node.targets:
|
||||
self._bind(target)
|
||||
|
||||
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
||||
self._bind(node.target)
|
||||
|
||||
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
||||
self._bind(node.target)
|
||||
|
||||
def visit_For(self, node: ast.For) -> None:
|
||||
self._bind(node.target)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
||||
self._bind(node.target)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_With(self, node: ast.With) -> None:
|
||||
for item in node.items:
|
||||
if item.optional_vars is not None:
|
||||
self._bind(item.optional_vars)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
||||
for item in node.items:
|
||||
if item.optional_vars is not None:
|
||||
self._bind(item.optional_vars)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def _module_level_names(tree: ast.AST) -> set[str]:
|
||||
visitor = _ModuleLevelNames()
|
||||
visitor.visit(tree)
|
||||
return visitor.names
|
||||
|
||||
|
||||
def _resolve_relative_import(path: Path, node: ast.ImportFrom, root: Path) -> Path | None:
|
||||
"""The file ``from ... import`` points at, or None when it leaves the package."""
|
||||
base = path.parent
|
||||
for _ in range(node.level - 1):
|
||||
base = base.parent
|
||||
if not base.is_relative_to(root):
|
||||
return None
|
||||
if not node.module:
|
||||
return base / "__init__.py"
|
||||
target = base.joinpath(*node.module.split("."))
|
||||
if target.is_dir():
|
||||
return target / "__init__.py"
|
||||
return target.with_suffix(".py")
|
||||
|
||||
|
||||
def _scan_relative_imports(path: Path, tree: ast.AST, report: Report) -> None:
|
||||
"""t41: resolve the integration's OWN imports, not only ``homeassistant.*``.
|
||||
|
||||
``_scan_imports`` proves the HA surface; it says nothing about
|
||||
``from .helpers import discover_adb_host`` once that helper is deleted — the
|
||||
exact defect that shipped from ``button.py`` because no harness loaded the
|
||||
file. This pass is pure AST against the tree, so it needs no importable
|
||||
Home Assistant and cannot be masked by a missing stub: the target file must
|
||||
exist and must define every name imported from it (a submodule counts too).
|
||||
"""
|
||||
root = _package_root(path)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ImportFrom) or not node.level:
|
||||
continue # absolute imports are _scan_imports' business
|
||||
spelling = f"{'.' * node.level}{node.module or ''}"
|
||||
target = _resolve_relative_import(path, node, root)
|
||||
if target is None:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import ... escapes the package — the "
|
||||
"integration may only import from itself",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not target.exists():
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import ...: no module at "
|
||||
f"{target.relative_to(root)}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
defined = _module_level_names(
|
||||
ast.parse(target.read_text(encoding="utf-8"), filename=str(target))
|
||||
)
|
||||
for alias in node.names:
|
||||
if alias.name == "*":
|
||||
continue
|
||||
report.relative_imports += 1
|
||||
if alias.name in defined:
|
||||
continue
|
||||
# `from .package import submodule` is valid without the name being
|
||||
# bound in __init__ — the import system resolves the submodule.
|
||||
sibling = target.parent / f"{alias.name}.py"
|
||||
sibling_package = target.parent / alias.name / "__init__.py"
|
||||
if sibling.exists() or sibling_package.exists():
|
||||
continue
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import {alias.name}: not defined in "
|
||||
f"{target.relative_to(root)}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _scan_package_imports(paths, report: Report) -> None:
|
||||
"""Walk the tree and resolve every one of the package's own imports."""
|
||||
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_relative_imports(file, tree, report)
|
||||
|
||||
|
||||
def scan_package_imports(paths=None) -> list[Finding]:
|
||||
"""The relative-import pass on its own (t41) — the fast suite's entry point."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_scan_package_imports(paths, report)
|
||||
return report.findings
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- keywords
|
||||
|
||||
|
||||
@@ -439,6 +701,254 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
|
||||
|
||||
# ----------------------------------------------------------- entity contracts
|
||||
|
||||
#: Attributes Home Assistant reads as ATTRIBUTES — it never calls them. A plain
|
||||
#: method with one of these names is invisible to the stub suites (permissive
|
||||
#: stubs, and the harness replaces ``async_write_ha_state``) and fatal under real
|
||||
#: 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``'s
|
||||
#: ``extra_state_attributes``, a property today (the old line number rotted).
|
||||
ENTITY_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"activity",
|
||||
"activity_list",
|
||||
"app_id",
|
||||
"app_name",
|
||||
"assumed_state",
|
||||
"attribution",
|
||||
"available",
|
||||
"capability_attributes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_picture",
|
||||
"extra_state_attributes",
|
||||
"icon",
|
||||
"is_volume_muted",
|
||||
"last_reset",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_id",
|
||||
"media_content_type",
|
||||
"media_duration",
|
||||
"media_image_url",
|
||||
"media_position",
|
||||
"media_title",
|
||||
"name",
|
||||
"native_unit_of_measurement",
|
||||
"native_value",
|
||||
"options",
|
||||
"should_poll",
|
||||
"sound_mode",
|
||||
"source",
|
||||
"source_list",
|
||||
"state",
|
||||
"state_attributes",
|
||||
"supported_features",
|
||||
"translation_key",
|
||||
"unique_id",
|
||||
"unit_of_measurement",
|
||||
"volume_level",
|
||||
}
|
||||
)
|
||||
|
||||
#: Entity base classes Home Assistant ships. A class counts as an entity when its
|
||||
#: transitive bases include one of these, so our own intermediate bases
|
||||
#: (``NvidiaShieldSensorBase``) are covered as well.
|
||||
HA_ENTITY_BASES = frozenset(
|
||||
{
|
||||
"AlarmControlPanelEntity",
|
||||
"BinarySensorEntity",
|
||||
"ButtonEntity",
|
||||
"CalendarEntity",
|
||||
"CameraEntity",
|
||||
"ClimateEntity",
|
||||
"CoverEntity",
|
||||
"DeviceTrackerEntity",
|
||||
"Entity",
|
||||
"EventEntity",
|
||||
"FanEntity",
|
||||
"HumidifierEntity",
|
||||
"ImageEntity",
|
||||
"LawnMowerEntity",
|
||||
"LightEntity",
|
||||
"LockEntity",
|
||||
"MediaPlayerEntity",
|
||||
"NotifyEntity",
|
||||
"NumberEntity",
|
||||
"RemoteEntity",
|
||||
"SelectEntity",
|
||||
"SensorEntity",
|
||||
"SirenEntity",
|
||||
"SwitchEntity",
|
||||
"TextEntity",
|
||||
"TodoListEntity",
|
||||
"UpdateEntity",
|
||||
"VacuumEntity",
|
||||
"ValveEntity",
|
||||
"WaterHeaterEntity",
|
||||
"WeatherEntity",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_attribute_decorated(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||
"""True when the method is declared ``@property`` / ``@cached_property``."""
|
||||
for decorator in node.decorator_list:
|
||||
name = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
|
||||
if name in {"property", "cached_property"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _class_bases(node: ast.ClassDef) -> set[str]:
|
||||
"""The names in a class's base list (``**kwargs``-style bases are ignored)."""
|
||||
return {
|
||||
base.id if isinstance(base, ast.Name) else getattr(base, "attr", "")
|
||||
for base in node.bases
|
||||
}
|
||||
|
||||
|
||||
def entity_class_names(tree: ast.Module) -> set[str]:
|
||||
"""Names of the classes in one module that inherit from an HA entity base."""
|
||||
bases = {
|
||||
node.name: _class_bases(node) for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
||||
}
|
||||
entities = {name for name, parents in bases.items() if parents & HA_ENTITY_BASES}
|
||||
# Transitive: our own intermediate bases (NvidiaShieldSensorBase → SensorEntity)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, parents in bases.items():
|
||||
if name not in entities and parents & entities:
|
||||
entities.add(name)
|
||||
changed = True
|
||||
return entities
|
||||
|
||||
|
||||
def _scan_entity_contracts(paths, report: Report) -> None:
|
||||
"""t40: flag HA-read attributes declared as plain methods.
|
||||
|
||||
The stub suites cannot see this class of defect — they call the method
|
||||
successfully, because the harnesses replace ``async_write_ha_state`` — and
|
||||
only real HA turns it into a TypeError. Pure AST, so it also runs when
|
||||
homeassistant is not installed.
|
||||
"""
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
continue # scan() reports bad-path for the same target
|
||||
files = _iter_python_files(root)
|
||||
for file in files:
|
||||
try:
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
entity_classes = entity_class_names(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef) or node.name not in entity_classes:
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
if item.name not in ENTITY_ATTRIBUTES or _is_attribute_decorated(item):
|
||||
continue
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(file),
|
||||
item.lineno,
|
||||
"entity-attribute-is-a-method",
|
||||
f"{node.name}.{item.name} is a plain method, but HA reads it as an "
|
||||
"attribute (``attr |= self.<name>``) — declare it @property",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def scan_entity_contracts(paths=None) -> list[Finding]:
|
||||
"""The entity-contract pass on its own (t40) — the fast suite's entry point."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_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
|
||||
|
||||
|
||||
@@ -455,6 +965,13 @@ def scan(paths=None) -> Report:
|
||||
paths = [paths]
|
||||
|
||||
report = Report()
|
||||
# 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
|
||||
@@ -484,7 +1001,7 @@ def scan(paths=None) -> Report:
|
||||
)
|
||||
)
|
||||
continue
|
||||
files = sorted(root.glob("*.py")) if root.is_dir() else [root]
|
||||
files = _iter_python_files(root)
|
||||
for file in files:
|
||||
report.modules += 1
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
|
||||
+1030
-87
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,443 @@
|
||||
"""tests/test_command_scripts.py — per-command script overrides (t44).
|
||||
|
||||
The user's remaining universal-media-player dependency: some commands need hardware
|
||||
this integration deliberately does not drive — a speaker switch that owns the
|
||||
volume, an AVR input that has to move with the Shield, a mute that has to hit two
|
||||
devices. One script per command is the escape hatch, and the rule is absolute:
|
||||
|
||||
configured -> that command calls the script, with its variables, and never
|
||||
touches the protocol or ADB path;
|
||||
unset -> the command behaves exactly as it did before this existed.
|
||||
|
||||
The suite is expected to stay quiet about the first case elsewhere: nothing else may
|
||||
start calling services, which is why tests/test_command_routing.py asserts that no
|
||||
service call happens on the native paths.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import (
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
SCRIPT_VAR_APP_NAME,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_LEVEL,
|
||||
SCRIPT_VAR_MUTED,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
)
|
||||
from test_command_routing import _remote_entity, make_manager, make_player
|
||||
|
||||
#: Commands with no data to pass — the script is called with the entity only.
|
||||
PLAIN_COMMANDS = ("turn_on", "turn_off", "volume_up", "volume_down")
|
||||
|
||||
WATCH = "script.shield_watch"
|
||||
|
||||
|
||||
@pytest.fixture(name="modules", autouse=True)
|
||||
def scoped_modules():
|
||||
"""Stub scope + loaded entity modules for one test, restored afterwards.
|
||||
|
||||
The t7 discipline: without the teardown this file's `nsh.*` modules and stub
|
||||
faces survive into whichever file runs next, and the next file's flow/entity
|
||||
classes bind against the wrong faces (the t44 file-order failure).
|
||||
"""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
try:
|
||||
yield entity_stublib.load_entity_modules()
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _services(served):
|
||||
"""A hass.services stand-in that records (domain, service, data)."""
|
||||
|
||||
class _RecordingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
served.append((domain, service, data))
|
||||
|
||||
return _RecordingServices()
|
||||
|
||||
|
||||
def _player(modules, scripts=None, *, is_on=None):
|
||||
player, served, _intents = make_player(modules)
|
||||
if is_on is not None:
|
||||
player._manager._on_is_on(is_on)
|
||||
if scripts is not None:
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||
player.hass.services = _services(served)
|
||||
return player, served
|
||||
|
||||
|
||||
def _script_call(script=WATCH, variables=None):
|
||||
data = {"entity_id": script}
|
||||
if variables:
|
||||
data["variables"] = variables
|
||||
return ("script", "turn_on", data)
|
||||
|
||||
|
||||
class TestMappedCommands:
|
||||
"""A configured script replaces the native path — for every command."""
|
||||
|
||||
@pytest.mark.parametrize("command", PLAIN_COMMANDS)
|
||||
async def test_a_plain_command_calls_its_script_and_nothing_else(
|
||||
self, modules, command
|
||||
):
|
||||
# every native path would have had work to do: ADB is up, and for the
|
||||
# power commands the state gate is open
|
||||
player, served = _player(modules, {command: WATCH}, is_on=command == "turn_off")
|
||||
|
||||
await getattr(player, f"async_{command}")()
|
||||
|
||||
assert served == [_script_call()]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_volume_set_passes_the_level(self, modules):
|
||||
player, served = _player(modules, {"volume_set": WATCH})
|
||||
|
||||
await player.async_set_volume_level(0.4)
|
||||
|
||||
# exactly the variable name and the value as passed, nothing extra
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_LEVEL: 0.4})]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
@pytest.mark.parametrize("mute", [True, False])
|
||||
async def test_volume_mute_passes_the_requested_state(self, modules, mute):
|
||||
"""A script cannot tell mute from unmute without this."""
|
||||
player, served = _player(modules, {"volume_mute": WATCH})
|
||||
|
||||
await player.async_mute_volume(mute)
|
||||
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_MUTED: mute})]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_select_source_passes_the_app_name(self, modules):
|
||||
player, served = _player(modules, {"select_source": WATCH})
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_APP_NAME: "TiviMate"})]
|
||||
|
||||
async def test_the_remote_entity_shares_the_same_turn_on_script(self, modules):
|
||||
"""There are two routes to POWER; one configuration governs both."""
|
||||
entity = _remote_entity(modules)
|
||||
served = []
|
||||
entity.hass = SimpleNamespace(services=_services(served))
|
||||
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
entity._manager._on_is_on(False) # the native path would fire
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert served == [_script_call()]
|
||||
assert entity._manager._protocol.key_calls == []
|
||||
|
||||
|
||||
class TestUnmappedCommands:
|
||||
"""With nothing configured, every command keeps its native path."""
|
||||
|
||||
async def test_no_service_call_happens_at_all(self, modules):
|
||||
player, served = _player(modules, {}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_volume_up()
|
||||
|
||||
assert served == [] # nothing left the integration
|
||||
|
||||
async def test_turn_on_still_sends_the_protocol_power_key(self, modules):
|
||||
player, _served = _player(modules, {}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||
|
||||
async def test_volume_up_still_uses_the_native_protocol_key(self, modules):
|
||||
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
|
||||
await player.async_volume_up()
|
||||
|
||||
assert player._manager._protocol.key_calls == [
|
||||
(PROTOCOL_REMOTE_KEYS["volume_up"], "SHORT")
|
||||
]
|
||||
|
||||
async def test_volume_up_still_falls_through_to_adb(self, modules):
|
||||
"""The second native tier, reached exactly when the protocol is down."""
|
||||
player, _served = _player(modules, {})
|
||||
player._manager._protocol = None
|
||||
|
||||
await player.async_volume_up()
|
||||
|
||||
assert player._manager._poller.media_calls == ["volume_up"]
|
||||
|
||||
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
|
||||
"""Absolute volume exists ONLY through a script (the protocol has no
|
||||
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
|
||||
player, served = _player(modules, {})
|
||||
|
||||
await player.async_set_volume_level(0.4)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
|
||||
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||
|
||||
player, served = _player(modules, {"volume_up": WATCH})
|
||||
|
||||
await player.async_volume_down()
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == [
|
||||
(PROTOCOL_REMOTE_KEYS["volume_down"], "SHORT")
|
||||
]
|
||||
|
||||
|
||||
class TestScriptFailures:
|
||||
"""A mapped command that cannot run must be visible, never a silent no-op."""
|
||||
|
||||
async def test_a_raising_script_surfaces_a_clean_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules, {"volume_up": WATCH}, is_on=False)
|
||||
|
||||
class _FailingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise RuntimeError("speaker switch is offline")
|
||||
|
||||
player.hass.services = _FailingServices()
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await player.async_volume_up()
|
||||
|
||||
# translated (t47): the command, the script and the cause are placeholders
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "command_script_failed"
|
||||
assert raised.value.translation_placeholders == {
|
||||
"action": "volume_up",
|
||||
"script": WATCH,
|
||||
"error": "speaker switch is offline",
|
||||
}
|
||||
# ...and it did NOT quietly fall back to the native path
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
|
||||
class TestOptionShape:
|
||||
"""The stored option is read through one normalizer, defensively."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored",
|
||||
[
|
||||
"script.watch", # not a dict at all
|
||||
{"turn_on": 5}, # not an entity id
|
||||
{"turn_on": " "}, # empty
|
||||
{"bogus_command": WATCH}, # unknown command
|
||||
None,
|
||||
],
|
||||
)
|
||||
async def test_a_hand_edited_option_never_reaches_the_service_layer(
|
||||
self, modules, stored
|
||||
):
|
||||
player, served = _player(modules, stored, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||
|
||||
async def test_a_padded_entity_id_is_trimmed(self, modules):
|
||||
player, served = _player(modules, {"turn_on": f" {WATCH} "}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == [_script_call()]
|
||||
|
||||
|
||||
class TestFeatureSet:
|
||||
"""A script-only command still has to be reachable, so it is advertised."""
|
||||
|
||||
def _features(self, player):
|
||||
return int(player.supported_features)
|
||||
|
||||
def test_volume_set_appears_only_when_configured(self, modules):
|
||||
"""volume_set has no native path at all, so the script is the only reach."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
baseline = self._features(player)
|
||||
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
|
||||
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
|
||||
with_volume = self._features(player)
|
||||
|
||||
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
|
||||
# ...and nothing else moved
|
||||
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
|
||||
|
||||
def test_select_source_is_advertised_once_there_is_something_to_offer(
|
||||
self, modules
|
||||
):
|
||||
"""t47: either a script OR a source list is enough — the native path exists
|
||||
now, so an unconfigured integration still has sources to offer."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
flagged = self._features(player)
|
||||
|
||||
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||
# a configured script changes nothing about the flag
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
|
||||
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||
|
||||
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
|
||||
player, _served = _player(modules, {})
|
||||
baseline = self._features(player)
|
||||
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
|
||||
assert self._features(player) == baseline
|
||||
|
||||
|
||||
def test_the_remote_entry_options_are_the_media_player_options(modules):
|
||||
"""Both entities read the same option key — pinned so they cannot drift."""
|
||||
player, _served = _player(modules, {"turn_on": WATCH})
|
||||
entity = _remote_entity(modules)
|
||||
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
|
||||
assert (
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
== entity._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
)
|
||||
assert make_manager().entry is None # the harness's manager starts unbound
|
||||
|
||||
|
||||
WATCH_SCRIPT = "script.shield_remote"
|
||||
|
||||
|
||||
class TestRemoteCommandScript:
|
||||
"""t47 B: one optional script for remote.send_command, replacing the key path.
|
||||
|
||||
A remote command is a SEQUENCE (repeats, long presses, delays), so the escape
|
||||
hatch is one script for the whole call rather than a per-key override: an IR
|
||||
bridge, a macro or a second device needs the call, not the keys.
|
||||
"""
|
||||
|
||||
def _entity(self, modules, script=None):
|
||||
entity = _remote_entity(modules)
|
||||
served = []
|
||||
entity.hass = SimpleNamespace(services=_services(served))
|
||||
if script is not None:
|
||||
entity._entry.options[CONF_REMOTE_COMMAND_SCRIPT] = script
|
||||
return entity, served
|
||||
|
||||
async def test_a_configured_script_receives_the_exact_parameters(self, modules):
|
||||
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
await entity.async_send_command(
|
||||
["HOME", "KEYCODE_DPAD_CENTER"],
|
||||
num_repeats=3,
|
||||
delay_secs=0.25,
|
||||
hold_secs=1.5,
|
||||
)
|
||||
|
||||
assert served == [
|
||||
(
|
||||
"script",
|
||||
"turn_on",
|
||||
{
|
||||
"entity_id": WATCH_SCRIPT,
|
||||
"variables": {
|
||||
SCRIPT_VAR_COMMAND: ["HOME", "KEYCODE_DPAD_CENTER"],
|
||||
SCRIPT_VAR_NUM_REPEATS: 3,
|
||||
SCRIPT_VAR_DELAY_SECS: 0.25,
|
||||
SCRIPT_VAR_HOLD_SECS: 1.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
assert entity._manager._protocol.key_calls == [] # key path untouched
|
||||
|
||||
async def test_the_service_defaults_are_named_too(self, modules):
|
||||
"""The variable set is fixed: a script can rely on all four being present."""
|
||||
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
await entity.async_send_command(["home"])
|
||||
|
||||
variables = served[0][2]["variables"]
|
||||
assert variables == {
|
||||
SCRIPT_VAR_COMMAND: ["home"],
|
||||
SCRIPT_VAR_NUM_REPEATS: 1,
|
||||
SCRIPT_VAR_DELAY_SECS: 0.0,
|
||||
SCRIPT_VAR_HOLD_SECS: 0.0,
|
||||
}
|
||||
|
||||
async def test_unset_keeps_the_native_key_path(self, modules):
|
||||
entity, served = self._entity(modules)
|
||||
|
||||
await entity.async_send_command(["HOME"], num_repeats=2, hold_secs=0.0)
|
||||
|
||||
assert served == []
|
||||
assert entity._manager._protocol.key_calls == [
|
||||
("KEYCODE_HOME", "SHORT"),
|
||||
("KEYCODE_HOME", "SHORT"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("stored", ["", " ", None, 7])
|
||||
async def test_a_blank_or_hand_edited_option_means_unset(self, modules, stored):
|
||||
entity, served = self._entity(modules, stored)
|
||||
|
||||
await entity.async_send_command(["HOME"])
|
||||
|
||||
assert served == []
|
||||
assert entity._manager._protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
|
||||
|
||||
async def test_a_raising_script_is_a_translated_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
entity, _served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
class _FailingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise RuntimeError("IR bridge is offline")
|
||||
|
||||
entity.hass.services = _FailingServices()
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await entity.async_send_command(["HOME"])
|
||||
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "command_script_failed"
|
||||
assert raised.value.translation_placeholders == {
|
||||
"action": "remote.send_command",
|
||||
"script": WATCH_SCRIPT,
|
||||
"error": "IR bridge is offline",
|
||||
}
|
||||
assert entity._manager._protocol.key_calls == [] # never a silent no-op
|
||||
|
||||
|
||||
def test_both_new_exception_keys_have_copy_in_both_files():
|
||||
"""A raised translation_key with no copy shows the user a bare key."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
|
||||
for key in ("unknown_source", "command_script_failed"):
|
||||
assert exceptions[key]["title"], (name, key)
|
||||
assert exceptions[key]["message"], (name, key)
|
||||
assert "{source}" in json.loads(
|
||||
(root / "strings.json").read_text(encoding="utf-8")
|
||||
)["exceptions"]["unknown_source"]["message"]
|
||||
assert "remote_command_script" in json.loads(
|
||||
(root / "translations/en.json").read_text(encoding="utf-8")
|
||||
)["options"]["step"]["init"]["data"]
|
||||
@@ -11,14 +11,14 @@ import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
@@ -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}
|
||||
@@ -127,11 +140,7 @@ def _install_import_stubs() -> None:
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
selector.EntitySelector = lambda *a, **k: None
|
||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||
selector.SelectSelector = lambda *a, **k: None
|
||||
selector.SelectSelectorConfig = lambda **k: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -164,9 +173,8 @@ def _flow(user_input_host: str, monkeypatch=None):
|
||||
flow._user_data = {
|
||||
"name": "Nvidia Shield",
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_ADB_HOST: user_input_host,
|
||||
CONF_HOST: user_input_host,
|
||||
CONF_ADB_PORT: 5555,
|
||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||
CONF_JELLYFIN_ENTITY: "",
|
||||
CONF_RECEIVER_ENTITY: "",
|
||||
CONF_RECEIVER_INPUT: "",
|
||||
@@ -189,15 +197,10 @@ def scoped_stubs():
|
||||
teardown, so later tests never observe the fake homeassistant/voluptuous
|
||||
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
||||
"""
|
||||
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_install_import_stubs()
|
||||
yield
|
||||
for name in _STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -222,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"
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
@@ -18,7 +19,7 @@ from nsh.const import (
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_POWER_ENTITY,
|
||||
@@ -109,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}
|
||||
@@ -219,11 +233,7 @@ def _install_stubs() -> None:
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
selector.EntitySelector = lambda *a, **k: None
|
||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||
selector.SelectSelector = lambda *a, **k: None
|
||||
selector.SelectSelectorConfig = lambda **k: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -278,15 +288,10 @@ def _entry_module():
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_stubs():
|
||||
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_install_stubs()
|
||||
yield
|
||||
for name in _STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
||||
@@ -296,17 +301,15 @@ def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtoco
|
||||
flow.hass = FakeHass(tmp_dir)
|
||||
flow._user_data = {
|
||||
"name": "mdarcy",
|
||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||
CONF_JELLYFIN_ENTITY: "",
|
||||
CONF_RECEIVER_ENTITY: "",
|
||||
CONF_RECEIVER_INPUT: "",
|
||||
CONF_POWER_ENTITY: "",
|
||||
CONF_ADB_ENABLED: False,
|
||||
CONF_ADB_STORE_APP_INFO: True,
|
||||
CONF_ADB_HOST: "",
|
||||
CONF_HOST: "shield.local", # t41: ONE address field
|
||||
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "shield.local",
|
||||
}
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
|
||||
@@ -348,9 +351,13 @@ 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_PROTOCOL_HOST] == "shield.local"
|
||||
assert result["options"][CONF_HOST] == "shield.local"
|
||||
|
||||
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
@@ -364,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]
|
||||
|
||||
@@ -374,7 +383,7 @@ class TestReauthFlow:
|
||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1", title="mdarcy",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
data={},
|
||||
)
|
||||
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
||||
@@ -396,12 +405,25 @@ class TestReauthFlow:
|
||||
|
||||
|
||||
class TestEntryMigration:
|
||||
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
|
||||
"""t41: v2 → v3 collapses the two address fields into CONF_HOST.
|
||||
|
||||
The protocol host wins, else the ADB host (both name the same Shield); the
|
||||
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
|
||||
travels the whole way in one call.
|
||||
"""
|
||||
|
||||
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=1,
|
||||
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
|
||||
data={},
|
||||
options={
|
||||
"adb_enabled": True,
|
||||
"adb_port": 5555,
|
||||
"jellyfin_entity": "jf.x",
|
||||
"adb_host": "10.0.0.31", # legacy address
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
@@ -411,26 +433,82 @@ class TestEntryMigration:
|
||||
# ADB options carried over byte-compatibly
|
||||
assert entry.options["adb_enabled"] is True
|
||||
assert entry.options["adb_port"] == 5555
|
||||
# Protocol fields default; host discovery (ADB fallback) stays active
|
||||
assert entry.options["jellyfin_entity"] == "jf.x"
|
||||
# the v2 default, then the v3 address collapse
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is False
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == ""
|
||||
assert hass.config_entries.updating_entry_calls == [
|
||||
(entry, {"options": entry.options, "version": 2})
|
||||
]
|
||||
assert entry.options[CONF_HOST] == "10.0.0.31"
|
||||
assert "adb_host" not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
|
||||
async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
options={CONF_PROTOCOL_ENABLED: True},
|
||||
data={},
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
|
||||
CONF_ADB_HOST: "10.0.0.1",
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.2"
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.1"
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
|
||||
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == ""
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_the_current_version_is_a_noop(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=3,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
assert hass.config_entries.updating_entry_calls == []
|
||||
|
||||
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
|
||||
async def test_downgrade_rejected(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(version=3, options={}, title="x")
|
||||
entry = SimpleNamespace(version=4, options={}, title="x")
|
||||
assert await module.async_migrate_entry(hass, entry) is False
|
||||
@@ -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
|
||||
+1409
-97
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}$?"
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
|
||||
|
||||
The stub suites drive entities through hand-written fakes and replace
|
||||
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
||||
perfectly healthy there while a live install raises
|
||||
``TypeError: 'method' object is not iterable`` on every state write and the
|
||||
entity never renders.
|
||||
|
||||
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
|
||||
suite and the drift sweep share ONE implementation — and the CI drift job, which
|
||||
runs against a real Home Assistant, gates the same code path.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import ha_api_drift
|
||||
from conftest import _SHIELD_DIR
|
||||
|
||||
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
|
||||
|
||||
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
|
||||
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
|
||||
_PRE_FIX = f'''
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
|
||||
|
||||
class Player(MediaPlayerEntity):
|
||||
{_PLAIN_DEF}
|
||||
return {{"app_id": "x"}}
|
||||
'''
|
||||
|
||||
_FIXED = f'''
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
|
||||
|
||||
class Player(MediaPlayerEntity):
|
||||
@property
|
||||
{_PLAIN_DEF}
|
||||
return {{"app_id": "x"}}
|
||||
'''
|
||||
|
||||
_INHERITED_BASE = '''
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
|
||||
|
||||
class Base(SensorEntity):
|
||||
"""Our own intermediate base — subclasses are entities too."""
|
||||
|
||||
|
||||
class Child(Base):
|
||||
def native_value(self):
|
||||
return "x"
|
||||
'''
|
||||
|
||||
|
||||
def _findings(tmp_path, source: str):
|
||||
(tmp_path / "module.py").write_text(source, encoding="utf-8")
|
||||
return ha_api_drift.scan_entity_contracts(tmp_path)
|
||||
|
||||
|
||||
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
|
||||
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
|
||||
assert ha_api_drift.scan_entity_contracts() == []
|
||||
|
||||
|
||||
def test_the_check_flags_the_pre_fix_shape(tmp_path):
|
||||
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
|
||||
|
||||
findings = _findings(tmp_path, _PRE_FIX)
|
||||
|
||||
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
|
||||
assert findings[0].lineno == expected_line
|
||||
assert "Player.extra_state_attributes" in findings[0].detail
|
||||
assert "@property" in findings[0].detail
|
||||
|
||||
|
||||
def test_the_check_accepts_the_property_form(tmp_path):
|
||||
assert _findings(tmp_path, _FIXED) == []
|
||||
|
||||
|
||||
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
|
||||
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
|
||||
findings = _findings(tmp_path, _INHERITED_BASE)
|
||||
|
||||
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
|
||||
|
||||
|
||||
def test_the_tree_classification_is_not_vacuous():
|
||||
"""A classifier that sees no entities would pass every check above."""
|
||||
classified: set[str] = set()
|
||||
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
|
||||
|
||||
assert {
|
||||
"NvidiaShieldMediaPlayer",
|
||||
"NvidiaShieldCurrentAppSensor",
|
||||
"NvidiaShieldNowPlayingSensor",
|
||||
"NvidiaShieldRemoteEntity",
|
||||
"NvidiaShieldPairAdbButton",
|
||||
} <= classified
|
||||
|
||||
|
||||
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
|
||||
"""One implementation: the sweep's report carries the contract pass too."""
|
||||
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
|
||||
|
||||
report = ha_api_drift.scan(tmp_path)
|
||||
kinds = [finding.kind for finding in report.findings]
|
||||
|
||||
# The contract pass runs FIRST (pure AST). What the API passes add after it
|
||||
# depends on the interpreter: with homeassistant installed they judge the
|
||||
# module's imports, without it the sweep says it cannot judge (both are
|
||||
# covered here so the check holds in either variant).
|
||||
assert kinds[0] == "entity-attribute-is-a-method"
|
||||
rest = set(kinds[1:])
|
||||
if ha_api_drift.homeassistant_available():
|
||||
# the API passes ran and found nothing wrong with this module
|
||||
assert rest == set(), rest
|
||||
else:
|
||||
assert "no-homeassistant" in rest
|
||||
|
||||
|
||||
def test_the_check_ignores_missing_paths_instead_of_raising():
|
||||
"""scan() owns the bad-path finding; the pass just skips."""
|
||||
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []
|
||||
@@ -12,9 +12,12 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import ha_api_drift
|
||||
import pytest
|
||||
@@ -41,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"
|
||||
@@ -103,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)
|
||||
|
||||
|
||||
@@ -206,3 +226,257 @@ def test_the_declared_python_floor_never_lags_homeassistant():
|
||||
f"but the installed homeassistant requires {declared} — raise the constant "
|
||||
f"and the setup-python version in .gitea/workflows/test.yml"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------- entity contract (t40, real HA)
|
||||
#
|
||||
# The stub suites cannot see this class of defect: their harness replaces
|
||||
# ``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
||||
# fine there. Only HA's own ``Entity.__async_calculate_state`` — which does
|
||||
# ``attr |= self.extra_state_attributes`` — turns it into
|
||||
# ``TypeError: 'method' object is not iterable`` and stops the entity from ever
|
||||
# rendering. These tests drive that real path.
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class _RecordingStates:
|
||||
"""Minimal ``hass.states``: keeps what HA's entity writes."""
|
||||
|
||||
def __init__(self):
|
||||
self.registry: dict = {}
|
||||
self.written: list = []
|
||||
|
||||
def get(self, entity_id):
|
||||
return self.registry.get(entity_id)
|
||||
|
||||
def async_set_internal(self, entity_id, state, attr, *args):
|
||||
self.written.append((entity_id, state, dict(attr)))
|
||||
|
||||
|
||||
def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
|
||||
"""Build a production entity under real homeassistant (no platform)."""
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
module = importlib.import_module(f"custom_components.nvidia_shield.{module_name}")
|
||||
states = _RecordingStates()
|
||||
hass = SimpleNamespace(
|
||||
loop_thread_id=threading.get_ident(),
|
||||
states=states,
|
||||
data={},
|
||||
config=SimpleNamespace(components=set()),
|
||||
)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
|
||||
)
|
||||
entity = getattr(module, class_name)(hass, entry)
|
||||
entity.entity_id = entity_id
|
||||
# HA's real translation-key paths (``unit_of_measurement``, device-class
|
||||
# names) need the platform's translation data; without it a translation-keyed
|
||||
# entity raises "cannot have a translation key ... before being added to the
|
||||
# entity platform". PlatformData is the real container, so the entity is
|
||||
# exercised with the same shape it sees in production.
|
||||
from homeassistant.helpers.entity_platform import PlatformData
|
||||
|
||||
entity.platform_data = PlatformData(
|
||||
hass, domain=module_name, platform_name="nvidia_shield"
|
||||
)
|
||||
# Not added through an EntityComponent: HA would warn once via
|
||||
# _suggest_report_issue (which needs hass.data["integrations"]) — set the
|
||||
# flag it uses to report that once instead of faking a whole platform.
|
||||
entity._no_platform_reported = True
|
||||
return entity, states
|
||||
|
||||
|
||||
def _write_ha_state(entity, states):
|
||||
"""Run HA's real state write and return what reached the state machine."""
|
||||
entity.async_write_ha_state()
|
||||
assert states.written, f"{type(entity).__name__} wrote no state"
|
||||
return states.written[-1]
|
||||
|
||||
|
||||
#: Every concrete entity class in the integration, exercised above against the
|
||||
#: REAL state machine. t40: the media player's defect was a state-write crash, so
|
||||
#: "the whole tree renders" is the contract under test, not a single entity.
|
||||
_REAL_HA_MATRIX = (
|
||||
("media_player", "NvidiaShieldMediaPlayer", None, "media_player.shield"),
|
||||
("sensor", "NvidiaShieldCurrentAppSensor", None, "sensor.shield_current_app"),
|
||||
(
|
||||
"sensor",
|
||||
"NvidiaShieldNowPlayingSensor",
|
||||
{"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"),
|
||||
)
|
||||
|
||||
#: Classified as an entity by the sweep, but an abstract intermediate base HA
|
||||
#: never instantiates — its concrete subclasses are in the matrix.
|
||||
_INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX
|
||||
)
|
||||
def test_entities_calculate_state_under_real_homeassistant(
|
||||
module_name, class_name, options, entity_id
|
||||
):
|
||||
"""t40 blocker: HA's own state write must not raise for ANY entity.
|
||||
|
||||
``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the
|
||||
extra attributes with ``attr |= ...``; the pre-fix media player raised
|
||||
``TypeError: 'method' object is not iterable`` here, so it could never render.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
module_name, class_name, options=options, entity_id=entity_id
|
||||
)
|
||||
|
||||
written_id, state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert written_id == entity_id
|
||||
assert isinstance(state, str)
|
||||
assert isinstance(attrs, dict)
|
||||
|
||||
|
||||
def test_the_media_player_extra_attributes_reach_the_state_machine():
|
||||
"""The property's payload must survive HA's merge, not merely not raise."""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
|
||||
"""A new entity class must join the matrix above, or this test fails.
|
||||
|
||||
The sweep classifies entities from the AST; this keeps the real-HA coverage
|
||||
in step with that classification instead of silently shrinking.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
classified: set[str] = set()
|
||||
for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")):
|
||||
classified |= ha_api_drift.entity_class_names(
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX}
|
||||
|
||||
uncovered = classified - covered - _INTERMEDIATE_BASES
|
||||
|
||||
assert uncovered == set(), (
|
||||
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,203 @@
|
||||
"""tests/test_harness_scoping.py — the stub harness must not depend on file order (t44).
|
||||
|
||||
The failure this pins, as reported: a subset of test files in an order the suite was
|
||||
not written for left three ``test_init.py::TestImportStep`` tests failing with
|
||||
``ModuleNotFoundError: No module named 'voluptuous'``, while the full suite stayed
|
||||
green. Two mechanisms produced it, and both are order-only:
|
||||
|
||||
1. ``entity_stublib.install()`` trusted a single sentinel
|
||||
(``homeassistant.components.media_player``). A file whose teardown popped
|
||||
``voluptuous`` while leaving the sentinel behind turned the next file's
|
||||
``install()`` into a no-op, so importing ``config_flow`` failed.
|
||||
2. A file's teardown restored only the names it had listed. A module it loaded
|
||||
(``nsh.diagnostics``, ``nsh.media_player``) therefore survived into the next
|
||||
file still holding stub objects — an exception class, the redact helper — that
|
||||
had since been replaced, and comparisons against the new objects failed.
|
||||
|
||||
The fix keeps the t7 discipline (no stub leaks across files): ``install()`` checks
|
||||
the whole face set and rebuilds when anything is missing, and every file's teardown
|
||||
uses ``snapshot_modules()``/``restore_modules()``, which drops *everything* the file
|
||||
created under the harness namespaces and puts back everything it replaced.
|
||||
|
||||
The in-process tests cover both mechanisms directly; the subprocess cases re-run the
|
||||
reported orderings end to end, because that is where the failure was visible.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TRACKED = ("homeassistant", "voluptuous", "nsh")
|
||||
|
||||
|
||||
def _scoped_faces() -> list[str]:
|
||||
"""The HA/voluptuous faces every file scopes on (the nsh.* names are modules)."""
|
||||
return [name for name in entity_stublib.ENTITIES_STUB_NAMES if not name.startswith("nsh.")]
|
||||
|
||||
|
||||
def _run(*paths: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
*paths,
|
||||
"-q",
|
||||
"--no-header",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
class TestStubFaceGuard:
|
||||
"""Mechanism 1: a partially popped face set must be repaired, not trusted."""
|
||||
|
||||
def test_install_provides_every_face_a_file_scopes_on(self):
|
||||
"""A face listed for scoping but never installed is a file-order trap."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
|
||||
missing = [name for name in _scoped_faces() if name not in sys.modules]
|
||||
|
||||
assert missing == []
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_complete_face_set_is_left_alone(self):
|
||||
"""The guard must still be an idempotence guard, not a rebuild."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
before = {name: sys.modules[name] for name in _scoped_faces()}
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
assert {name: sys.modules[name] for name in _scoped_faces()} == before
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_sentinel_left_behind_does_not_hide_a_missing_face(self):
|
||||
"""The reported poisoning: media_player present, voluptuous gone."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
kept = sys.modules["homeassistant.components.media_player"]
|
||||
del sys.modules["voluptuous"]
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
assert "voluptuous" in sys.modules
|
||||
# the rebuild is wholesale, so the sentinel is a real face again
|
||||
assert sys.modules["homeassistant.components.media_player"] is not None
|
||||
assert kept is not None
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
class TestTeardownLeaks:
|
||||
"""Mechanism 2: nothing a file created or replaced may survive its teardown."""
|
||||
|
||||
def test_teardown_removes_the_modules_a_file_loaded(self):
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
entity_stublib.load_entity_modules()
|
||||
created = [
|
||||
name
|
||||
for name in sys.modules
|
||||
if name.startswith("nsh.") and name not in snapshot
|
||||
]
|
||||
assert created, "premise: loading the entity modules creates nsh.* modules"
|
||||
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
assert [name for name in sys.modules if name.startswith("nsh.")] == [
|
||||
name for name in snapshot if name.startswith("nsh.")
|
||||
]
|
||||
assert {
|
||||
name: sys.modules.get(name)
|
||||
for name in snapshot
|
||||
if name.split(".", 1)[0] in TRACKED
|
||||
} == snapshot
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_reloaded_module_holds_the_current_stub_objects(self):
|
||||
"""The stale-object failure, exactly: diagnostics vs the redact helper."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
stale_module = entity_stublib.load_entity_modules()["diagnostics"]
|
||||
stale = stale_module.async_redact_data
|
||||
entity_stublib.restore_modules(snapshot) # ...the file ends
|
||||
|
||||
# ...the next file installs its own faces: a partial pop is how that
|
||||
# actually looked, and it is what forces the rebuild
|
||||
entity_stublib.install()
|
||||
del sys.modules["homeassistant.helpers.redact"]
|
||||
entity_stublib.install()
|
||||
fresh_modules = entity_stublib.load_entity_modules()
|
||||
|
||||
current = sys.modules["homeassistant.helpers.redact"].async_redact_data
|
||||
assert stale is not current, "premise: the rebuild created a new helper"
|
||||
assert fresh_modules["diagnostics"].async_redact_data is current
|
||||
# ...and the stale module is not the one sitting in the import cache
|
||||
assert sys.modules["nsh.diagnostics"] is fresh_modules["diagnostics"]
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def test_every_file_that_loads_the_harness_scopes_it():
|
||||
"""A missing teardown is invisible until the NEXT file runs — so check for it.
|
||||
|
||||
This is the mistake the fix itself invites: a new test file calls
|
||||
load_entity_modules() for convenience and forgets the per-file scope, and the
|
||||
failure surfaces somewhere else entirely (t44 added exactly that file).
|
||||
"""
|
||||
#: Files that exercise the harness in a CHILD process: they leave nothing in
|
||||
#: this process's sys.modules, so they need no teardown.
|
||||
child_process_only = {"test_module_coverage.py"}
|
||||
offenders = []
|
||||
for path in sorted((REPO_ROOT / "tests").glob("test_*.py")):
|
||||
if path.name in child_process_only:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
loads = "load_entity_modules(" in text or "entity_stublib.install()" in text
|
||||
if loads and "snapshot_modules()" not in text:
|
||||
offenders.append(path.name)
|
||||
|
||||
assert offenders == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"paths",
|
||||
[
|
||||
# the reported reproduction, cut to the two files that trigger it
|
||||
("tests/test_standalone_setup.py", "tests/test_init.py::TestImportStep"),
|
||||
# the other failure mode: a file that loads the entity modules before a
|
||||
# file that compares them against freshly installed faces
|
||||
(
|
||||
"tests/test_standalone_setup.py",
|
||||
"tests/test_config_flow_protocol.py",
|
||||
"tests/test_init.py",
|
||||
"tests/test_command_routing.py::TestAdbCommandService",
|
||||
"tests/test_command_routing.py::TestFaithfulHaStubs",
|
||||
"tests/test_command_routing.py::TestServiceRegistryKey",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_arbitrary_file_orders_pass(paths):
|
||||
"""Order is the thing under test, so it is executed, not reasoned about."""
|
||||
result = _run(*paths)
|
||||
|
||||
assert result.returncode == 0, result.stdout[-3000:]
|
||||
+37
-14
@@ -39,19 +39,14 @@ _INIT_STUB_NAMES = (
|
||||
@pytest.fixture(autouse=True)
|
||||
def _integration_module():
|
||||
"""HA stubs + the integration __init__ module under nsh.init."""
|
||||
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
if "nsh.init" not in sys.modules:
|
||||
import conftest
|
||||
|
||||
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||
yield sys.modules["nsh.init"]
|
||||
for name in _INIT_STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
class _SeedEntriesStub:
|
||||
@@ -119,8 +114,9 @@ class TestYamlSeedBlocks:
|
||||
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
||||
|
||||
def test_block_normalized(self, _integration_module):
|
||||
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||||
from nsh.const import CONF_ADB_PORT, CONF_HOST, DEFAULT_ADB_PORT
|
||||
|
||||
# t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||
)
|
||||
@@ -128,20 +124,31 @@ class TestYamlSeedBlocks:
|
||||
assert len(blocks) == 1
|
||||
seed = blocks[0]
|
||||
assert seed["name"] == "Living"
|
||||
assert seed[CONF_ADB_HOST] == "10.0.0.31"
|
||||
assert seed[CONF_HOST] == "10.0.0.31"
|
||||
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||
assert seed["adb_enabled"] is True
|
||||
assert "unique_id" not in seed # optional key only
|
||||
|
||||
def test_the_current_host_key_seeds_too(self, _integration_module):
|
||||
from nsh.const import CONF_HOST
|
||||
|
||||
seed = _integration_module._yaml_seed_blocks(
|
||||
[{"name": "Desk", CONF_HOST: "shield.local"}]
|
||||
)[0]
|
||||
|
||||
assert seed[CONF_HOST] == "shield.local"
|
||||
assert seed["adb_enabled"] is True # an address names the Shield
|
||||
|
||||
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||
)
|
||||
|
||||
assert len(blocks) == 1
|
||||
# protocol fields are prefill hints ONLY (pairing stays interactive)
|
||||
assert blocks[0]["protocol_host"] == "shield.local"
|
||||
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
|
||||
assert blocks[0]["host"] == "shield.local"
|
||||
assert blocks[0]["adb_port"] == 5000
|
||||
assert blocks[0]["adb_enabled"] is False
|
||||
|
||||
def test_list_and_single_block_shapes(self, _integration_module):
|
||||
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||
@@ -166,7 +173,23 @@ class TestSeeding:
|
||||
domain, context, data = hass.config_entries.flows[0]
|
||||
assert domain == "nvidia_shield"
|
||||
assert context["source"] == "import"
|
||||
assert data["adb_host"] == "10.0.0.5"
|
||||
assert data["host"] == "10.0.0.5" # t41: one address field
|
||||
|
||||
|
||||
class TestSeedingCurrentKey:
|
||||
"""The t41 `host` key seeds exactly like the legacy one."""
|
||||
|
||||
async def test_host_key_seeds_a_flow(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
|
||||
await _integration_module.async_setup(
|
||||
hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]}
|
||||
)
|
||||
if hass.scheduled_tasks:
|
||||
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||
|
||||
assert len(hass.config_entries.flows) == 1
|
||||
assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7"
|
||||
|
||||
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
@@ -229,12 +252,12 @@ class TestImportStep:
|
||||
return flow
|
||||
|
||||
async def test_ui_configured_host_never_re_seeded(self):
|
||||
from nsh.const import CONF_ADB_HOST
|
||||
from nsh.const import CONF_HOST
|
||||
|
||||
existing = SimpleNamespace(
|
||||
entry_id="ui1",
|
||||
unique_id=None,
|
||||
options={CONF_ADB_HOST: "10.0.0.31"},
|
||||
options={CONF_HOST: "10.0.0.31"},
|
||||
)
|
||||
# options live on the entry (conf zeroconf guard reads them)
|
||||
flow = self._flow([existing])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""tests/test_media_sources.py — source_list, source and native select_source (t47).
|
||||
|
||||
The last piece a universal-media-player replacement needs from this integration:
|
||||
a dropdown of the apps (``source_list``), an answer to "what is on screen"
|
||||
(``source``), and a way to pick one (``media_player.select_source``). All three read
|
||||
the SAME app directory — the user's Apps map, else the curated default — so the
|
||||
dropdown cannot offer a name the launch path cannot resolve.
|
||||
|
||||
The t44 rule still holds: a configured ``select_source`` script takes precedence,
|
||||
because selecting a source can be more than launching an app. Without one, the name
|
||||
resolves back to its package and goes out over the shared ``play_media("app", …)``
|
||||
path; an unknown name is a translated error, never a silent no-op.
|
||||
"""
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import CONF_APP_NAME_MAP, CONF_COMMAND_SCRIPTS, SCRIPT_VAR_APP_NAME
|
||||
from test_command_routing import make_player
|
||||
|
||||
TIVIMATE = "ar.tvplayer.tv"
|
||||
SCRIPT = "script.shield_source"
|
||||
|
||||
|
||||
@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, *, app_map=None, scripts=None, foreground=None):
|
||||
player, served, _intents = make_player(modules)
|
||||
if app_map is not None:
|
||||
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||
if scripts is not None:
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||
if foreground is not None:
|
||||
player._manager._on_current_app(foreground)
|
||||
return player, served
|
||||
|
||||
|
||||
class TestSourceList:
|
||||
"""The dropdown: friendly names, in a stable order, from the one directory."""
|
||||
|
||||
def test_it_comes_from_the_apps_map(self, modules):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={"com.custom.one": "One", "com.custom.two": "Two"},
|
||||
)
|
||||
|
||||
assert player.source_list == ["One", "Two"] # map order, not shuffled
|
||||
|
||||
def test_it_falls_back_to_the_curated_directory(self, modules):
|
||||
player, _served = _player(modules) # no map configured
|
||||
|
||||
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
|
||||
|
||||
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
|
||||
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
|
||||
|
||||
An empty entry is worst than useless: it is a dropdown item that
|
||||
select_source has to reject as an unknown source.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={
|
||||
"com.blank": "",
|
||||
TIVIMATE: "TiviMate",
|
||||
"com.spaces": " ",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
},
|
||||
)
|
||||
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert "" not in player.source_list
|
||||
|
||||
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)
|
||||
|
||||
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)."""
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={"com.blank": "", TIVIMATE: "TiviMate", "com.blank2": " "},
|
||||
)
|
||||
|
||||
for name in player.source_list:
|
||||
assert player._source_package(name) is not None
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
# the blank entries are not selectable, and never launch
|
||||
assert player._source_package("") is None
|
||||
assert player._source_package(" ") is None
|
||||
|
||||
def test_every_offered_name_resolves_to_a_package(self, modules):
|
||||
"""The invariant the shared directory buys: no name without a destination.
|
||||
|
||||
t51 extends it to the hand-edited shapes: blanks, padded names and two
|
||||
entries normalizing to one name all still leave only resolvable offers.
|
||||
"""
|
||||
for app_map in (
|
||||
None,
|
||||
{"com.custom.one": "One"},
|
||||
{
|
||||
"com.blank": "",
|
||||
"com.blank.package": " ",
|
||||
TIVIMATE: " TiviMate ",
|
||||
"com.a": "Netflix",
|
||||
"com.b": " Netflix ",
|
||||
"": "Ghost",
|
||||
},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
|
||||
for name in player.source_list:
|
||||
assert player._source_package(name) is not None, name
|
||||
|
||||
|
||||
class TestSource:
|
||||
"""`source` is the foreground app's resolved name — the app_name chain."""
|
||||
|
||||
def test_it_resolves_through_the_apps_map(self, modules):
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate"}, foreground=TIVIMATE
|
||||
)
|
||||
|
||||
assert player.source == "TiviMate"
|
||||
assert player.source == player.app_name # one truth, not two
|
||||
|
||||
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
|
||||
player, _served = _player(modules, foreground="com.netflix.ninja")
|
||||
player._manager.cache_app_label("com.netflix.ninja", "Netflix")
|
||||
|
||||
assert player.source == "Netflix"
|
||||
|
||||
def test_it_falls_back_to_the_raw_package(self, modules):
|
||||
player, _served = _player(modules, foreground="com.unknown.app")
|
||||
|
||||
assert player.source == "com.unknown.app"
|
||||
|
||||
def test_it_is_unknown_when_nothing_has_reported(self, modules):
|
||||
player, _served = _player(modules)
|
||||
|
||||
assert player.source is None
|
||||
|
||||
|
||||
class TestSelectSource:
|
||||
"""Picking a source: script first, else the native launch, else an error."""
|
||||
|
||||
async def test_a_name_launches_its_package(self, modules):
|
||||
player, served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
assert served == [] # no script was configured, and nothing else left
|
||||
|
||||
async def test_the_curated_names_launch_too(self, modules):
|
||||
player, _served = _player(modules)
|
||||
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.netflix.ninja"]
|
||||
|
||||
async def test_a_raw_package_and_any_case_are_accepted(self, modules):
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
await player.async_select_source(TIVIMATE)
|
||||
await player.async_select_source("tivimate")
|
||||
await player.async_select_source(" NETFLIX ")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [
|
||||
TIVIMATE,
|
||||
TIVIMATE,
|
||||
"com.netflix.ninja",
|
||||
]
|
||||
|
||||
async def test_a_configured_script_still_wins(self, modules):
|
||||
player, served = _player(
|
||||
modules,
|
||||
app_map={TIVIMATE: "TiviMate"},
|
||||
scripts={"select_source": SCRIPT},
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [
|
||||
(
|
||||
"script",
|
||||
"turn_on",
|
||||
{"entity_id": SCRIPT, "variables": {SCRIPT_VAR_APP_NAME: "TiviMate"}},
|
||||
)
|
||||
]
|
||||
assert player._manager._protocol.launch_calls == [] # native path untouched
|
||||
|
||||
async def test_an_unknown_name_raises_a_translated_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules, app_map={TIVIMATE: "TiviMate"})
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await player.async_select_source("Not An App")
|
||||
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "unknown_source"
|
||||
assert raised.value.translation_placeholders == {"source": "Not An App"}
|
||||
assert player._manager._protocol.launch_calls == [] # nothing was sent
|
||||
|
||||
async def test_an_empty_name_is_an_error_too(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules)
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await player.async_select_source(" ")
|
||||
|
||||
|
||||
class TestAdvertisedFeatures:
|
||||
"""HA routes a service by its feature flag, so the flag has to be honest."""
|
||||
|
||||
def test_a_source_list_advertises_select_source(self, modules):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules)
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_a_script_advertises_it_even_with_no_directory(self, modules, monkeypatch):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, scripts={"select_source": SCRIPT})
|
||||
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_neither_means_it_is_not_offered(self, modules, monkeypatch):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules)
|
||||
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||
|
||||
assert not player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_select_source_is_not_gated_on_the_volume_set_script(self, modules):
|
||||
"""The two script-only commands stay independent."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, scripts={"volume_set": "script.volume"})
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
|
||||
class TestNormalizedDirectories:
|
||||
"""t51: a hand-edited map is normalized ONCE, in the shared accessor.
|
||||
|
||||
The two residuals t49 left share one cause — `_app_directory` handed the raw map
|
||||
out, so the Browse tree showed a child with a blank title and the reverse lookup
|
||||
compared the user's padded name against itself. The policy now lives in that one
|
||||
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 = {
|
||||
"com.blank": "",
|
||||
TIVIMATE: "TiviMate",
|
||||
"com.spaces": " ",
|
||||
"": "Ghost",
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
assert list(player._app_directory) == [TIVIMATE]
|
||||
assert list(player._app_directory.values()) == ["TiviMate"]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
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"}
|
||||
)
|
||||
|
||||
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):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
|
||||
)
|
||||
|
||||
# display and match key are one string, everywhere
|
||||
assert player.source_list == ["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
|
||||
assert player._source_package("tivimate") == TIVIMATE
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_two_packages_with_one_name_keep_a_deterministic_winner(self, modules):
|
||||
"""Both apps stay reachable; the NAME goes to the first in map order."""
|
||||
player, _served = _player(
|
||||
modules, app_map={"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"
|
||||
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.a"]
|
||||
|
||||
# map order decides — the user's order, not chance
|
||||
swapped, _served_swapped = _player(
|
||||
modules, app_map={"com.b": "Netflix", "com.a": "Netflix"}
|
||||
)
|
||||
|
||||
assert swapped._source_package("Netflix") == "com.b"
|
||||
|
||||
async def test_a_wellformed_map_passes_through_unchanged(self, modules):
|
||||
"""Stripping touches the EDGES: an inner space is part of the name."""
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
assert player._app_directory == {
|
||||
TIVIMATE: "TiviMate Pro",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||
|
||||
|
||||
class TestCaseInsensitiveNames:
|
||||
"""t53: one row per NAME, compared case-insensitively — as the lookup does.
|
||||
|
||||
The dropdown and the reverse lookup disagreed about case: two entries called
|
||||
`Dup` and `dup` survived an exact-match dedupe as two rows, while
|
||||
`_source_package` — case-insensitive by design (t47) — resolved BOTH to the
|
||||
first entry. So the second row launched the first entry's app: a row that lies
|
||||
about what it does, and the one shape here the editor can produce by itself.
|
||||
"""
|
||||
|
||||
async def test_names_differing_only_by_case_are_offered_once(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
# one row, keeping the first spelling the user typed
|
||||
assert player.source_list == ["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"})
|
||||
|
||||
await player.async_select_source(player.source_list[0])
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.a"] # the row it shows
|
||||
|
||||
async def test_the_shadowed_app_stays_reachable_by_id(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
assert player._source_package("com.b") == "com.b"
|
||||
|
||||
await player.async_select_source("com.b")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||
|
||||
# the shadowed NAME still resolves to the one the list offers (first wins)
|
||||
assert player._source_package("dup") == "com.a"
|
||||
|
||||
async def test_the_first_spelling_wins_in_map_order(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.b": "dup", "com.a": "Dup"})
|
||||
|
||||
assert player.source_list == ["dup"]
|
||||
|
||||
await player.async_select_source("dup")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||
|
||||
async def test_a_mixed_map_offers_each_name_once(self, modules):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={
|
||||
"com.a": "Dup",
|
||||
"com.b": "dup",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.google.android.youtube.tv": "netflix",
|
||||
TIVIMATE: "TiviMate Pro",
|
||||
},
|
||||
)
|
||||
|
||||
assert player.source_list == ["Dup", "Netflix", "TiviMate Pro"]
|
||||
assert player._source_package("Netflix") == "com.netflix.ninja"
|
||||
|
||||
def test_the_offered_list_never_repeats_a_name(self, modules):
|
||||
"""The invariant behind the fix, over every map shape in this file."""
|
||||
for app_map in (
|
||||
None,
|
||||
{"com.a": "Dup", "com.b": "dup"},
|
||||
{"com.a": "Dup", "com.b": "DUP", "com.c": "dup"},
|
||||
{"com.blank": "", TIVIMATE: " TiviMate ", "com.a": "Netflix", "com.b": "netflix"},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
folded = [name.casefold() for name in player.source_list]
|
||||
|
||||
assert len(folded) == len(set(folded)), app_map
|
||||
assert len(folded) == len(player.source_list)
|
||||
|
||||
def test_a_padded_map_value_is_displayed_verbatim_by_app_name(self, modules):
|
||||
"""The README's boundary, pinned: the list cleans it, app_name does not.
|
||||
|
||||
The name map is the user's own override, so it is used as typed — which is
|
||||
why `source` can be a string that is not in source_list.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: " TiviMate "}, foreground=TIVIMATE
|
||||
)
|
||||
|
||||
assert player.source_list == ["TiviMate"] # the dropdown cleans it
|
||||
assert player.source == " TiviMate " # this surface shows it as typed
|
||||
assert player.source not in player.source_list
|
||||
|
||||
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
|
||||
"""Blanks are not displayed as nothing — the chain continues past them."""
|
||||
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
|
||||
|
||||
assert player.source == TIVIMATE
|
||||
@@ -0,0 +1,204 @@
|
||||
"""tests/test_module_coverage.py — nothing in the package may dangle (t41).
|
||||
|
||||
Two defects motivated this file, and both shipped with a green suite:
|
||||
|
||||
1. ``button.py`` imported a helper that had been deleted. No harness ever loaded
|
||||
that file (``entity_stublib.load_entity_modules`` lists the platforms it
|
||||
knows), so the ImportError would have appeared only in a user's log. The fix
|
||||
is exhaustive by construction: the sweep walks the package for ``*.py`` files
|
||||
in a clean interpreter, under the stub harness, and loads every one of them.
|
||||
A new platform is covered the moment the file exists — there is no list to
|
||||
update and no special case.
|
||||
|
||||
2. The drift sweep resolved ``homeassistant.*`` imports only, so it could not
|
||||
see a relative import of a name that no longer exists — the same defect one
|
||||
layer up. ``ha_api_drift.scan_package_imports`` now resolves the package's own
|
||||
imports from the AST, with no Home Assistant and therefore no stub needed.
|
||||
|
||||
Each sweep carries its own red proof, and nothing about the pre-t41 tree supplies
|
||||
it. At ``c588a7c`` ``helpers.py`` still DEFINED ``discover_adb_host``, so there was
|
||||
no broken relative import to find; and that commit's engine predates BOTH passes
|
||||
(its ``ha_api_drift.py`` has no entity-contract check and no package-import check
|
||||
at all), so a real-HA run there is simply clean — exit 0, "no Home-Assistant API
|
||||
drift (13 modules …)", no findings. The proof is therefore built, never borrowed:
|
||||
a copy of the tree with the ``discover_adb_host`` reference actually reverted must
|
||||
fail each sweep, or the check is decoration.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import ha_api_drift
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = _SHIELD_DIR.parent.parent
|
||||
|
||||
#: Loads every module of a package under the stub harness, in a clean process.
|
||||
#: ``argv``: 1 = this tests directory, 2 = the package directory to sweep.
|
||||
_DRIVER = r"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
tests_dir, package_root = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
sys.path.insert(0, str(tests_dir))
|
||||
sys.path.insert(0, str(package_root))
|
||||
|
||||
import entity_stublib # noqa: E402
|
||||
from conftest import _load_module # noqa: E402
|
||||
|
||||
# conftest eagerly loads a few stdlib-only modules from the REAL tree (it cannot
|
||||
# know about a copied one): point the synthetic package at the tree under test
|
||||
# and drop what it imported, so every relative import resolves inside the copy.
|
||||
sys.modules["nsh"].__path__ = [str(package_root)]
|
||||
for name in [n for n in list(sys.modules) if n.startswith("nsh.")]:
|
||||
del sys.modules[name]
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
loaded, failures = [], []
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
rel = path.relative_to(package_root)
|
||||
if rel.name == "__init__.py":
|
||||
dotted = ".".join(rel.parent.parts)
|
||||
name = "nsh.init" if dotted in ("", ".") else f"nsh.{dotted}"
|
||||
else:
|
||||
name = "nsh." + ".".join(rel.with_suffix("").parts)
|
||||
search = [str(path.parent)] if rel.name == "__init__.py" else None
|
||||
try:
|
||||
_load_module(name, path, search)
|
||||
except Exception as exc: # noqa: BLE001 - the error IS the finding
|
||||
failures.append(f"{rel}: {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
loaded.append(str(rel))
|
||||
|
||||
for rel in loaded:
|
||||
print(f"OK {rel}")
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
print(f"SUMMARY loaded={len(loaded)} failed={len(failures)} total={len(loaded) + len(failures)}")
|
||||
sys.exit(1 if failures else 0)
|
||||
"""
|
||||
|
||||
|
||||
def _package_modules(root: Path) -> set[str]:
|
||||
"""Every Python file of a package, as the sweep reports them."""
|
||||
return {
|
||||
str(path.relative_to(root))
|
||||
for path in root.rglob("*.py")
|
||||
if "__pycache__" not in path.parts
|
||||
}
|
||||
|
||||
|
||||
def _sweep(root: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", _DRIVER, str(TESTS_DIR), str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _reverted_copy(tmp_path: Path) -> Path:
|
||||
"""A copy of the package where button.py imports the deleted helper again."""
|
||||
target = tmp_path / "nvidia_shield"
|
||||
shutil.copytree(
|
||||
_SHIELD_DIR,
|
||||
target,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.json", "*.png"),
|
||||
)
|
||||
button = target / "button.py"
|
||||
button.write_text(
|
||||
"from .helpers import discover_adb_host # the reverted reference\n"
|
||||
+ button.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def test_every_module_imports_under_the_stub_harness():
|
||||
"""The whole package loads in a clean interpreter — no module is skipped."""
|
||||
result = _sweep(_SHIELD_DIR)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
assert result.returncode == 0, output
|
||||
reported = {
|
||||
line.split(" ", 1)[1]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.startswith(("OK ", "FAIL "))
|
||||
}
|
||||
assert reported == _package_modules(_SHIELD_DIR), "the sweep skipped or invented a file"
|
||||
assert "failed=0" in result.stdout, output
|
||||
|
||||
|
||||
def test_the_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||
"""Red proof 1: the exact defect that motivated this check.
|
||||
|
||||
button.py imported ``discover_adb_host`` from helpers after that helper was
|
||||
deleted (t41 removed it with the entity path). The sweep must fail loudly.
|
||||
"""
|
||||
result = _sweep(_reverted_copy(tmp_path))
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
assert result.returncode != 0, output
|
||||
assert "discover_adb_host" in output
|
||||
assert "FAIL button.py" in result.stdout
|
||||
|
||||
|
||||
def test_every_relative_import_resolves():
|
||||
"""The same contract, statically: the package's own imports all resolve."""
|
||||
findings = ha_api_drift.scan_package_imports()
|
||||
|
||||
assert findings == [], "\n".join(str(finding) for finding in findings)
|
||||
|
||||
|
||||
def test_the_relative_import_pass_is_not_vacuous():
|
||||
"""A pass that resolved nothing would be green for the wrong reason."""
|
||||
report = ha_api_drift.Report()
|
||||
|
||||
ha_api_drift._scan_package_imports([_SHIELD_DIR], report)
|
||||
|
||||
assert report.relative_imports >= ha_api_drift.FLOORS["relative_imports"]
|
||||
# and the walk itself reached every module, not only the top level (the
|
||||
# floor sits above the top-level-only count, so a shallow glob trips it)
|
||||
assert len(_package_modules(_SHIELD_DIR)) >= ha_api_drift.FLOORS["modules"]
|
||||
|
||||
|
||||
def test_the_static_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||
"""Red proof 2: the AST pass sees the same defect, with no Home Assistant."""
|
||||
findings = ha_api_drift.scan_package_imports(_reverted_copy(tmp_path))
|
||||
|
||||
assert [finding.kind for finding in findings] == ["broken-relative-import"]
|
||||
assert "discover_adb_host" in findings[0].detail
|
||||
assert findings[0].path.endswith("button.py")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
# a name nobody defines
|
||||
("from .helpers import no_such_helper\n", "broken-relative-import"),
|
||||
# a relative import that leaves the package
|
||||
("from ..sibling import thing\n", "broken-relative-import"),
|
||||
# a submodule import is valid without a binding in __init__
|
||||
("from .ha_adb_common import adb_client\n", None),
|
||||
],
|
||||
)
|
||||
def test_the_pass_judges_the_cases_that_matter(tmp_path, source, expected):
|
||||
package = tmp_path / "nvidia_shield"
|
||||
(package / "ha_adb_common").mkdir(parents=True)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "helpers.py").write_text("def entry_host(entry):\n return ''\n", encoding="utf-8")
|
||||
(package / "ha_adb_common" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "ha_adb_common" / "adb_client.py").write_text("", encoding="utf-8")
|
||||
(package / "module.py").write_text(source, encoding="utf-8")
|
||||
|
||||
findings = ha_api_drift.scan_package_imports(package)
|
||||
|
||||
assert [finding.kind for finding in findings] == ([expected] if expected else [])
|
||||
@@ -0,0 +1,72 @@
|
||||
"""tests/test_readme.py — the README's user-facing contracts (t41).
|
||||
|
||||
Documentation drifts silently: the seed example is read by people who then
|
||||
report "the key did nothing". These pins are deliberately textual — no YAML
|
||||
parser dependency — but they check the two things that actually hurt:
|
||||
|
||||
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
|
||||
honours (nothing documented that the integration ignores);
|
||||
* the replacement guide names the integrations and the glue it retires, so the
|
||||
t41 ruling stays visible to users, not only in the changelog.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
|
||||
# seed block reaches the entry (the rest is the interactive flow's or the
|
||||
# options page's to set).
|
||||
SEEDED_KEYS = {
|
||||
"name",
|
||||
"host",
|
||||
"adb_port",
|
||||
"adb_enabled",
|
||||
"protocol_host", # legacy, still accepted
|
||||
"adb_host", # legacy, still accepted
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def readme_text() -> str:
|
||||
return README.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _seed_example(text: str) -> str:
|
||||
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
|
||||
assert match, "the README has no `nvidia_shield:` example block"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_the_example_block_only_uses_seeded_keys(readme_text):
|
||||
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
|
||||
|
||||
assert keys, "the example block declares no keys"
|
||||
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
|
||||
|
||||
|
||||
def test_the_example_block_documents_the_one_address_field(readme_text):
|
||||
example = _seed_example(readme_text)
|
||||
|
||||
assert "host:" in example
|
||||
# the legacy spellings are still explained, as back-compat
|
||||
assert "protocol_host" in readme_text and "adb_host" in readme_text
|
||||
|
||||
|
||||
def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
||||
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
|
||||
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 == []
|
||||
@@ -0,0 +1,654 @@
|
||||
"""tests/test_release_versioning.py — the release version decision (t42).
|
||||
|
||||
Why this file exists: the version the workflow picks decides whether HACS ever
|
||||
offers a user the update. Every wrong answer is silent — the tag and the release
|
||||
still appear, only the manifest never moves — and the real evidence is in the
|
||||
repo history: v2026.10.09 was published while master's manifest still read
|
||||
2026.10.08. The decision therefore lives in ``scripts/determine-release-version.sh``
|
||||
and every branch below is driven directly, rather than trusted to a CI run.
|
||||
|
||||
The four scenarios the task names, plus the boundary cases that made the old
|
||||
inline shell wrong:
|
||||
|
||||
(a) manifest version untagged -> manifest wins (BUMP=no)
|
||||
(b) manifest tagged elsewhere, date free -> date
|
||||
(c) manifest AND date tagged -> same-day CALVER suffix, lowest free N
|
||||
(d) the commit is already released -> RELEASE=no, explicit no-op signal
|
||||
|
||||
The script is also the only place that reads the clock, and it reads it in UTC.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "scripts" / "determine-release-version.sh"
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
||||
MANIFEST = REPO_ROOT / "custom_components" / "nvidia_shield" / "manifest.json"
|
||||
|
||||
HEAD = "a" * 40
|
||||
OTHER = "b" * 40
|
||||
TODAY = "2026.10.11"
|
||||
|
||||
|
||||
def _resolve(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
manifest_version: str,
|
||||
tags=(),
|
||||
today: str | None = TODAY,
|
||||
head: str | None = HEAD,
|
||||
env: dict | None = None,
|
||||
):
|
||||
"""Run the resolver against a fixture manifest and tag list."""
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": manifest_version}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tag_list = tmp_path / "tags"
|
||||
tag_list.write_text(
|
||||
"".join(f"{tag} {sha}\n" for tag, sha in tags), encoding="utf-8"
|
||||
)
|
||||
|
||||
argv = [
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--manifest",
|
||||
str(manifest),
|
||||
"--tags-from",
|
||||
str(tag_list),
|
||||
]
|
||||
if today is not None:
|
||||
argv += ["--today", today]
|
||||
if head is not None:
|
||||
argv += ["--head", head]
|
||||
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **(env or {})},
|
||||
check=False,
|
||||
)
|
||||
values = dict(
|
||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||
)
|
||||
return result, values
|
||||
|
||||
|
||||
# ── (a) the manifest wins while its version is untagged ──────────────────────
|
||||
|
||||
|
||||
def test_an_untagged_manifest_version_is_released_as_is(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.11", tags=[("v2026.10.09", OTHER)]
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == "2026.10.11"
|
||||
assert values["TAG"] == "v2026.10.11"
|
||||
assert values["SOURCE"] == "manifest"
|
||||
assert values["RELEASE"] == "yes"
|
||||
# the manifest already says it: no write-back commit, so the tag lands on
|
||||
# the pushed commit itself
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
|
||||
def test_a_leading_v_in_the_manifest_is_tolerated(tmp_path):
|
||||
"""The tag adds the `v`; a hand-written one must not become the version."""
|
||||
_result, values = _resolve(tmp_path, manifest_version="v2026.10.11")
|
||||
|
||||
assert values["VERSION"] == "2026.10.11"
|
||||
assert values["TAG"] == "v2026.10.11"
|
||||
|
||||
|
||||
# ── (b) once the manifest version is tagged, the date takes over ─────────────
|
||||
|
||||
|
||||
def test_a_tagged_manifest_version_falls_back_to_the_utc_date(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER)]
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == TODAY
|
||||
assert values["SOURCE"] == "date"
|
||||
assert values["RELEASE"] == "yes"
|
||||
# the manifest still says the old version: the workflow must write the new
|
||||
# one back, or HACS keeps comparing against 2026.10.09 forever
|
||||
assert values["BUMP"] == "yes"
|
||||
|
||||
|
||||
# ── (c) a second release in one UTC day ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_tagged_date_falls_back_to_a_same_day_suffix(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)],
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == f"{TODAY}.2"
|
||||
assert values["SOURCE"] == "same-day"
|
||||
assert values["RELEASE"] == "yes"
|
||||
assert values["BUMP"] == "yes"
|
||||
|
||||
|
||||
def test_the_suffix_is_the_lowest_untagged_n_from_two(tmp_path):
|
||||
"""Not "highest + 1": a gap is filled, so a failed run does not burn a number."""
|
||||
_result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[
|
||||
("v2026.10.09", OTHER),
|
||||
(f"v{TODAY}", OTHER),
|
||||
(f"v{TODAY}.2", OTHER),
|
||||
],
|
||||
)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.3"
|
||||
|
||||
_result, values = _resolve(
|
||||
tmp_path / "second",
|
||||
manifest_version="2026.10.09",
|
||||
tags=[
|
||||
("v2026.10.09", OTHER),
|
||||
(f"v{TODAY}", OTHER),
|
||||
(f"v{TODAY}.2", OTHER),
|
||||
(f"v{TODAY}.3", OTHER),
|
||||
(f"v{TODAY}.5", OTHER), # a gap at .4 must be reused
|
||||
],
|
||||
)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.4"
|
||||
|
||||
|
||||
# ── (d) nothing to do ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_an_already_released_commit_is_an_explicit_no_op(tmp_path):
|
||||
"""A re-run (manual dispatch, a retried job) must not invent a new version."""
|
||||
result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[("v2026.10.09", HEAD)], # this very commit is the release
|
||||
head=HEAD,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["RELEASE"] == "no"
|
||||
assert values["VERSION"] == "2026.10.09"
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["BUMP"] == "no"
|
||||
assert "nothing to release" in result.stderr
|
||||
|
||||
|
||||
def test_a_release_is_never_planned_for_a_tag_that_exists(tmp_path):
|
||||
"""The property the backstop defends, over a spread of tag sets.
|
||||
|
||||
RELEASE=yes must always mean "this version is not tagged yet": that is what
|
||||
makes the workflow's tag step safe without re-checking.
|
||||
"""
|
||||
scenarios = [
|
||||
("2026.10.09", []),
|
||||
("2026.10.09", [("v2026.10.09", OTHER)]),
|
||||
("2026.10.09", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||
("2026.10.11", [("v2026.10.08", OTHER)]),
|
||||
(f"{TODAY}.2", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||
("2026.10.09", [("v2026.10.09", HEAD)]), # already released
|
||||
]
|
||||
|
||||
for index, (manifest_version, tags) in enumerate(scenarios):
|
||||
_result, values = _resolve(
|
||||
tmp_path / f"case{index}", manifest_version=manifest_version, tags=tags
|
||||
)
|
||||
tagged = {tag for tag, _sha in tags}
|
||||
|
||||
if values["RELEASE"] == "yes":
|
||||
assert values["TAG"] not in tagged, (manifest_version, tags, values)
|
||||
else:
|
||||
assert values["SOURCE"] in {"released", "tagged"}
|
||||
|
||||
|
||||
# ── (e) UTC determinism ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_date_is_utc_whatever_the_runner_timezone_is(tmp_path):
|
||||
"""A version number must not depend on where the job happened to run."""
|
||||
tags = [("v2026.09.30", OTHER)] # the manifest version is tagged -> date
|
||||
runs = []
|
||||
for timezone in ("Pacific/Kiritimati", "Etc/GMT+12", "America/Anchorage"):
|
||||
_result, values = _resolve(
|
||||
tmp_path / timezone.replace("/", "_"),
|
||||
manifest_version="2026.09.30",
|
||||
tags=tags,
|
||||
today=None, # exercise the real clock
|
||||
env={"TZ": timezone},
|
||||
)
|
||||
runs.append(values["VERSION"])
|
||||
|
||||
utc_today = subprocess.run(
|
||||
["date", "-u", "+%Y.%m.%d"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
assert len(set(runs)) == 1, runs
|
||||
assert runs[0] == utc_today
|
||||
|
||||
|
||||
# ── usage and error handling ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_missing_manifest_fails_loudly(tmp_path):
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--manifest", str(tmp_path / "nope.json"), "--today", TODAY],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "manifest not found" in result.stderr
|
||||
|
||||
|
||||
def test_a_non_calver_manifest_version_fails_loudly(tmp_path):
|
||||
result, _values = _resolve(tmp_path, manifest_version="1.2.3")
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "not YYYY.MM.DD" in result.stderr
|
||||
|
||||
|
||||
def test_an_unknown_argument_is_a_usage_error(tmp_path):
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--nonsense"], capture_output=True, text=True, check=False
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "unknown argument" in result.stderr
|
||||
|
||||
|
||||
def test_github_output_receives_the_same_values(tmp_path):
|
||||
"""The workflow consumes $GITHUB_OUTPUT, so the script must fill it too."""
|
||||
output = tmp_path / "github_output"
|
||||
output.write_text("", encoding="utf-8")
|
||||
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.11", env={"GITHUB_OUTPUT": str(output)}
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
written = dict(
|
||||
line.split("=", 1)
|
||||
for line in output.read_text(encoding="utf-8").splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
assert written == values
|
||||
assert set(written) == {"VERSION", "TAG", "SOURCE", "BUMP", "RELEASE"}
|
||||
|
||||
|
||||
# ── the workflow contract (textual: the fast suite has no YAML parser) ───────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workflow_text() -> str:
|
||||
return WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_the_workflow_uses_the_tested_script(workflow_text):
|
||||
assert "scripts/determine-release-version.sh" in workflow_text
|
||||
|
||||
|
||||
def test_the_workflow_writes_the_manifest_back_and_tags_that_commit(workflow_text):
|
||||
# the exact commit message the task pins, with the skip-ci marker
|
||||
assert "chore(release): v${VERSION} [skip ci]" in workflow_text
|
||||
# the push happens before the tag, so the tag names the bumped commit
|
||||
assert "git push origin HEAD:master" in workflow_text
|
||||
assert 'git push origin "refs/tags/${TAG}"' in workflow_text
|
||||
|
||||
|
||||
def test_a_rejected_bump_push_fails_with_a_manual_instruction(workflow_text):
|
||||
assert "could not push" in workflow_text
|
||||
assert "bump" in workflow_text.lower() and "manually" in workflow_text.lower()
|
||||
|
||||
|
||||
def test_the_workflow_verifies_the_pushed_manifest_before_publishing(workflow_text):
|
||||
assert "origin/master:${MANIFEST}" in workflow_text
|
||||
assert "inconsistent" in workflow_text.lower()
|
||||
|
||||
|
||||
def test_the_bot_commit_cannot_trigger_another_release(workflow_text):
|
||||
# two layers: the conventional skip-ci marker on the commit, and a job-level
|
||||
# guard that re-checks the message (the guard is what must hold even if a
|
||||
# future Gitea stops honouring the marker)
|
||||
assert "[skip ci]" in workflow_text
|
||||
assert "github.event.head_commit.message" in workflow_text
|
||||
assert "workflow_dispatch" in workflow_text
|
||||
|
||||
|
||||
def test_the_skip_guard_matches_the_subject_not_the_whole_message(workflow_text):
|
||||
"""t44 R3: `contains(message, '[skip ci]')` skipped a release silently.
|
||||
|
||||
A developer commit whose BODY mentions the marker is not a release commit, so
|
||||
the guard is anchored to the release bot's subject prefix instead.
|
||||
"""
|
||||
assert "!startsWith(github.event.head_commit.message || '', 'chore(release):')" in (
|
||||
workflow_text
|
||||
)
|
||||
assert "!contains(github.event.head_commit.message" not in workflow_text
|
||||
|
||||
|
||||
def test_the_release_notes_exclude_the_write_back_commit(workflow_text):
|
||||
"""t44 R1: with a write-back the tag IS the bot commit, so notes must skip it."""
|
||||
assert 'if [ "$BUMP" = "yes" ]; then' in workflow_text
|
||||
assert 'NOTES_END="${TAG}^"' in workflow_text
|
||||
assert 'RANGE="${PREV}..${NOTES_END}"' in workflow_text
|
||||
assert 'BUMP: ${{ steps.version.outputs.BUMP }}' in workflow_text
|
||||
|
||||
|
||||
def test_the_preserved_release_behaviour_is_still_there(workflow_text):
|
||||
# one release at a time, never cancelled mid-flight
|
||||
assert "group: release-master" in workflow_text
|
||||
assert "cancel-in-progress: false" in workflow_text
|
||||
# notes from the commit range
|
||||
assert "git log --pretty=format" in workflow_text
|
||||
# the 409-tolerant publish path
|
||||
assert "409 is" in workflow_text or "409" in workflow_text
|
||||
assert "/releases/tags/" in workflow_text
|
||||
# the zip asset
|
||||
assert "attachment=@${ZIP_NAME}" in workflow_text
|
||||
|
||||
|
||||
def test_the_script_reads_the_manifest_the_workflow_bumps():
|
||||
"""One path, so a moved manifest cannot silently release a stale version."""
|
||||
assert MANIFEST.is_file()
|
||||
assert "custom_components/nvidia_shield/manifest.json" in SCRIPT.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
manifest_version = json.loads(MANIFEST.read_text(encoding="utf-8"))["version"]
|
||||
# the script rejects anything else, so a typo here would fail every release
|
||||
assert re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?", manifest_version)
|
||||
|
||||
|
||||
# ── t44 3c: real repositories, and the workflow's own steps by execution ─────
|
||||
|
||||
MANIFEST_REL = "custom_components/nvidia_shield/manifest.json"
|
||||
|
||||
|
||||
def _git(repo, *args) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=repo, capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
def _repo(tmp_path, *, version="2026.10.09", commits=("a feature commit",)):
|
||||
"""A real repository: the resolver's tag lookups really are git commands."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(parents=True)
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "dev@example.com")
|
||||
_git(repo, "config", "user.name", "dev")
|
||||
manifest = repo / MANIFEST_REL
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
for index, subject in enumerate(commits):
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{"domain": "nvidia_shield", "version": version, "note": index}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-qm", subject)
|
||||
return repo
|
||||
|
||||
|
||||
def _resolve_in(repo, *, today=TODAY):
|
||||
"""Run the resolver inside a real repo — no tag fixtures, the real git paths."""
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--manifest", MANIFEST_REL, "--today", today],
|
||||
cwd=repo, capture_output=True, text=True, check=False,
|
||||
)
|
||||
values = dict(
|
||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||
)
|
||||
return result, values
|
||||
|
||||
|
||||
class TestRealRepositories:
|
||||
"""The tagged-version branches against real git (t44 3c).
|
||||
|
||||
The fixture-based tests above never exercise `refs/tags/X^{commit}`, and that
|
||||
dereference is the whole reason an annotated tag is recognised: for one,
|
||||
`git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a comparison
|
||||
that forgot to dereference would miss a released commit entirely.
|
||||
"""
|
||||
|
||||
def test_an_annotated_tag_on_head_is_already_released(self, tmp_path):
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||
|
||||
result, values = _resolve_in(repo)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["RELEASE"] == "no"
|
||||
assert "nothing to release" in result.stderr
|
||||
# premise: the tag object really is not the commit
|
||||
assert _git(repo, "rev-parse", "v2026.10.09").strip() != _git(
|
||||
repo, "rev-parse", "HEAD"
|
||||
).strip()
|
||||
|
||||
def test_a_lightweight_tag_on_head_is_also_already_released(self, tmp_path):
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "v2026.10.09")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["RELEASE"] == "no"
|
||||
|
||||
def test_a_tag_on_an_older_commit_falls_back_to_the_date(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("the released work", "later work"))
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == TODAY
|
||||
assert values["SOURCE"] == "date"
|
||||
assert values["BUMP"] == "yes"
|
||||
assert values["RELEASE"] == "yes"
|
||||
|
||||
def test_no_tags_at_all_means_the_manifest_wins(self, tmp_path):
|
||||
repo = _repo(tmp_path, version="2026.10.10")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == "2026.10.10"
|
||||
assert values["SOURCE"] == "manifest"
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
def test_the_same_day_scan_walks_real_tags(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("released", "more"))
|
||||
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.2"):
|
||||
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.3"
|
||||
assert values["SOURCE"] == "same-day"
|
||||
|
||||
def test_a_gap_in_the_suffixes_is_reused(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("released", "more"))
|
||||
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.3"):
|
||||
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.2"
|
||||
|
||||
def test_the_guard_has_a_script_side_layer(self, tmp_path):
|
||||
"""Even with the job guard bypassed, a released commit releases nothing.
|
||||
|
||||
The job-level `if:` cannot be executed here (no Gitea runner), but the
|
||||
decision it protects can: this is the layer that makes a bypass harmless.
|
||||
"""
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||
# a re-run of the same push, guard or no guard
|
||||
for _attempt in range(2):
|
||||
_result, values = _resolve_in(repo)
|
||||
assert values["RELEASE"] == "no"
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
|
||||
def _step_script(name: str) -> str:
|
||||
"""The `run:` body of one workflow step, read by hand.
|
||||
|
||||
The fast suite has no YAML parser (the verify command parses the file with
|
||||
pyyaml instead), and executing these steps is worth more than matching their
|
||||
text, so the block is taken verbatim and dedented.
|
||||
"""
|
||||
lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == f"- name: {name}")
|
||||
run_at = next(
|
||||
i for i in range(start, len(lines)) if lines[i].strip() in {"run: |", "run: >-"}
|
||||
)
|
||||
indent = len(lines[run_at + 1]) - len(lines[run_at + 1].lstrip())
|
||||
body = []
|
||||
for line in lines[run_at + 1 :]:
|
||||
if line.strip() and not line.startswith(" " * indent):
|
||||
break
|
||||
body.append(line[indent:] if len(line) >= indent else "")
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
def _run_step(name: str, cwd, env):
|
||||
return subprocess.run(
|
||||
["bash", "-euo", "pipefail", "-c", _step_script(name)],
|
||||
cwd=cwd, capture_output=True, text=True, env={**os.environ, **env},
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowStepsByExecution:
|
||||
"""Run the workflow's own steps against a bare origin (t44 3c).
|
||||
|
||||
Textual pins cannot tell whether the bump lands on the commit the tag names;
|
||||
these do, and they are what the dry run in the task output was.
|
||||
"""
|
||||
|
||||
VERSION = "2026.10.11"
|
||||
TAG = "v2026.10.11"
|
||||
|
||||
def _clone(self, tmp_path, *, protect=False):
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", "-q", "-b", "master", str(origin)], check=True
|
||||
)
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True)
|
||||
_git(work, "config", "user.email", "dev@example.com")
|
||||
_git(work, "config", "user.name", "dev")
|
||||
manifest = work / MANIFEST_REL
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": "2026.10.08"}) + "\n"
|
||||
)
|
||||
_git(work, "add", "-A")
|
||||
_git(work, "commit", "-qm", "a feature commit")
|
||||
_git(work, "push", "-q", "origin", "master")
|
||||
if protect:
|
||||
hook = origin / "hooks" / "pre-receive"
|
||||
hook.write_text("#!/bin/sh\necho 'protected branch' >&2\nexit 1\n")
|
||||
hook.chmod(0o755)
|
||||
return work
|
||||
|
||||
def _env(self):
|
||||
return {
|
||||
"MANIFEST": MANIFEST_REL,
|
||||
"VERSION": self.VERSION,
|
||||
"TAG": self.TAG,
|
||||
}
|
||||
|
||||
def test_the_bump_verify_and_tag_steps_agree_on_one_commit(self, tmp_path):
|
||||
work = self._clone(tmp_path)
|
||||
|
||||
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||
assert bump.returncode == 0, bump.stderr
|
||||
assert _git(work, "log", "-1", "--pretty=%s").strip() == (
|
||||
f"chore(release): {self.TAG} [skip ci]"
|
||||
)
|
||||
|
||||
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||
assert verify.returncode == 0, verify.stderr
|
||||
assert f"matching {self.TAG}" in verify.stdout
|
||||
|
||||
tag = _run_step("Create tag", work, self._env())
|
||||
assert tag.returncode == 0, tag.stderr
|
||||
|
||||
head = _git(work, "rev-parse", "HEAD").strip()
|
||||
assert _git(work, "rev-parse", f"{self.TAG}^{{commit}}").strip() == head
|
||||
tagged_manifest = json.loads(_git(work, "show", f"{self.TAG}:{MANIFEST_REL}"))
|
||||
assert tagged_manifest["version"] == self.VERSION
|
||||
assert _git(work, "ls-remote", "--tags", "origin", self.TAG).split()[1] == (
|
||||
f"refs/tags/{self.TAG}"
|
||||
)
|
||||
|
||||
def test_the_verify_step_refuses_an_inconsistent_manifest(self, tmp_path):
|
||||
work = self._clone(tmp_path)
|
||||
_run_step("Write the version back into the manifest", work, self._env())
|
||||
# someone else's release lands on master before this one is verified
|
||||
(work / MANIFEST_REL).write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": "2026.10.99"}) + "\n"
|
||||
)
|
||||
_git(work, "add", "-A")
|
||||
_git(work, "commit", "-qm", "a competing release")
|
||||
_git(work, "push", "-q", "origin", "master")
|
||||
|
||||
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||
|
||||
assert verify.returncode == 1
|
||||
assert "inconsistent" in verify.stderr
|
||||
assert "2026.10.99" in verify.stderr
|
||||
|
||||
def test_the_notes_range_describes_the_developers_commits(self, tmp_path):
|
||||
"""t44 R1, executed: the range the notes use must not open with the bot commit.
|
||||
|
||||
With a write-back the tag IS the bot's commit, so `PREV..TAG` (the range
|
||||
before this fix) published "- chore(release): vX [skip ci]" as the first
|
||||
line of every release. The range is built from ``BUMP``, and this runs both
|
||||
halves through real git.
|
||||
"""
|
||||
work = self._clone(tmp_path)
|
||||
_git(work, "tag", "-a", "v2026.10.08", "-m", "previous release")
|
||||
_git(work, "commit", "--allow-empty", "-qm", "feat: the real change")
|
||||
_run_step("Write the version back into the manifest", work, self._env())
|
||||
_run_step("Create tag", work, self._env())
|
||||
|
||||
def subjects(rev_range):
|
||||
return _git(work, "log", "--pretty=format:- %s", rev_range)
|
||||
|
||||
notes_end = f"{self.TAG}^" # BUMP=yes: the write-back commit is skipped
|
||||
prev = _git(work, "describe", "--tags", "--abbrev=0", f"{notes_end}^").strip()
|
||||
|
||||
assert prev == "v2026.10.08"
|
||||
assert subjects(f"{prev}..{notes_end}") == "- feat: the real change"
|
||||
# ...and the range this replaced really did include the bot's own subject
|
||||
assert "- chore(release):" in subjects(f"{prev}..{self.TAG}")
|
||||
|
||||
def test_a_rejected_bump_push_fails_and_tags_nothing(self, tmp_path):
|
||||
work = self._clone(tmp_path, protect=True)
|
||||
|
||||
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||
|
||||
assert bump.returncode == 1
|
||||
assert "could not push" in bump.stderr
|
||||
assert "manually" in bump.stderr
|
||||
assert MANIFEST_REL in bump.stderr
|
||||
assert _git(work, "tag", "-l").strip() == ""
|
||||
+290
-190
@@ -9,6 +9,7 @@ migration validity for v2 entries without the field.
|
||||
|
||||
import asyncio # noqa: F401 (async steps need the event loop)
|
||||
import gc
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import warnings
|
||||
@@ -16,16 +17,18 @@ from pathlib import Path # noqa: E402 (F1 test key staging)
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
import voluptuous_mirror as vol_mirror
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
SCRIPT_COMMANDS,
|
||||
SCRIPT_FIELD_PREFIX,
|
||||
)
|
||||
|
||||
_STUB_NAMES = (
|
||||
@@ -100,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}
|
||||
@@ -135,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):
|
||||
@@ -156,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)
|
||||
@@ -214,11 +250,7 @@ def _install_stubs() -> None:
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
selector.EntitySelector = lambda *a, **k: None
|
||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||
selector.SelectSelector = lambda *a, **k: None
|
||||
selector.SelectSelectorConfig = lambda **k: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -262,15 +294,10 @@ class FakeHass:
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_stubs():
|
||||
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_install_stubs()
|
||||
yield
|
||||
for name in _STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _form_module():
|
||||
@@ -289,12 +316,21 @@ def _entry_module():
|
||||
return sys.modules["nsh.entry"]
|
||||
|
||||
|
||||
def _record(sink):
|
||||
"""An async_set_unique_id stand-in that records what the flow tried."""
|
||||
|
||||
async def _set(unique_id):
|
||||
sink.append(unique_id)
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
def _user_input(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "mdarcy",
|
||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||
CONF_PROTOCOL_ENABLED: False,
|
||||
CONF_PROTOCOL_HOST: "",
|
||||
CONF_HOST: "",
|
||||
CONF_ADB_ENABLED: False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
@@ -306,21 +342,25 @@ class TestStandaloneValidation:
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
|
||||
result = await flow.async_step_user(_user_input())
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "no_state_source"}
|
||||
|
||||
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
|
||||
async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path):
|
||||
"""t41: we own the stack — no other integration is a state source."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(_user_input())
|
||||
result = await flow.async_step_user(None)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
||||
fields = vol_mirror.declared_fields(result["data_schema"])
|
||||
assert CONF_ANDROIDTV_ENTITY not in fields
|
||||
# the two native transports are what is left to choose from
|
||||
assert CONF_PROTOCOL_ENABLED in fields
|
||||
assert CONF_ADB_ENABLED in fields
|
||||
|
||||
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||
module = _form_module()
|
||||
@@ -345,29 +385,28 @@ class TestStandaloneValidation:
|
||||
)
|
||||
|
||||
result = await flow.async_step_user(
|
||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
|
||||
_user_input(**{CONF_PROTOCOL_ENABLED: True})
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||
|
||||
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
||||
"""No androidtv entity → the key is absent from entry data (not "")."""
|
||||
async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
|
||||
"""t41: every value lives in options; `data` carries no leftovers."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow._user_data = {
|
||||
"name": "mdarcy",
|
||||
CONF_ANDROIDTV_ENTITY: "",
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "shield.local",
|
||||
CONF_HOST: "shield.local",
|
||||
}
|
||||
|
||||
# skip the pairing route: drive _create_entry directly
|
||||
result = flow._create_entry()
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
||||
assert result["data"] == {}
|
||||
|
||||
|
||||
class TestAdbOnlyStandalone:
|
||||
@@ -380,7 +419,6 @@ class TestAdbOnlyStandalone:
|
||||
result = await flow.async_step_user(
|
||||
_user_input(
|
||||
**{
|
||||
CONF_ANDROIDTV_ENTITY: "",
|
||||
CONF_PROTOCOL_ENABLED: False,
|
||||
"adb_enabled": True,
|
||||
}
|
||||
@@ -398,54 +436,155 @@ class TestAdbOnlyStandalone:
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(
|
||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False})
|
||||
_user_input(**{"adb_enabled": False})
|
||||
)
|
||||
|
||||
assert result["errors"] == {"base": "no_state_source"}
|
||||
|
||||
|
||||
class TestMigrationKeepsOrAllowsAbsence:
|
||||
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
|
||||
class TestMigrationDropsTheEntityKey:
|
||||
"""t41: the androidtv key is dropped from BOTH dicts with a logged note.
|
||||
|
||||
An entry that still carries it keeps working until then (nothing reads it) —
|
||||
the migration is what removes the last trace of the entity path.
|
||||
"""
|
||||
|
||||
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=1,
|
||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
options={},
|
||||
options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
with caplog.at_level(logging.INFO):
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
|
||||
# options were rewritten with the protocol defaults; the data dict —
|
||||
# including the androidtv field — was never touched
|
||||
assert hass.config_entries.updating_entry_calls == [
|
||||
(
|
||||
entry,
|
||||
{
|
||||
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
|
||||
"version": 2,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert CONF_ANDROIDTV_ENTITY not in entry.data
|
||||
assert CONF_ANDROIDTV_ENTITY not in entry.options
|
||||
assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
|
||||
assert entry.data == {}
|
||||
# the note names what was dropped, so the log is actionable
|
||||
notes = [record.getMessage() for record in caplog.records]
|
||||
assert any("media_player.shield" in note for note in notes), notes
|
||||
assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
|
||||
|
||||
async def test_v2_without_the_field_is_valid(self, tmp_path):
|
||||
"""Standalone entries born v2 must pass through migration untouched."""
|
||||
async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
notes = [record.getMessage() for record in caplog.records]
|
||||
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
|
||||
|
||||
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
|
||||
"""An entry written before the unification lands on CONF_HOST."""
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
|
||||
assert entry.options[CONF_HOST] == "shield.local"
|
||||
assert "protocol_host" not in entry.options
|
||||
|
||||
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
|
||||
# _form_module owns the scoped stub install/pop — never a bare
|
||||
# entity_stublib.install() here: a shallow stub set leaks into the
|
||||
# entity-module loader of later tests.
|
||||
module = _form_module()
|
||||
from nsh.const import ENTRY_VERSION
|
||||
|
||||
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):
|
||||
@@ -468,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"}
|
||||
@@ -482,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)
|
||||
@@ -512,11 +651,36 @@ 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"}
|
||||
|
||||
async def test_a_nameless_row_is_rejected_by_the_editor(self, tmp_path):
|
||||
"""t49: the editor already refuses 'com.x=' — a blank name cannot get in.
|
||||
|
||||
The source list filters blanks anyway, because the map can also be
|
||||
hand-edited; this pins the other half: the page will not STORE one.
|
||||
"""
|
||||
module = _form_module()
|
||||
|
||||
assert module._app_map_from_text("com.blank=", {}) is None
|
||||
assert module._app_map_from_text("com.blank= ", {}) is None
|
||||
|
||||
flow = module.NvidiaShieldOptionsFlow()
|
||||
flow.config_entry = SimpleNamespace(options={}, title="Shield")
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await _options_submit(
|
||||
flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestChannelEntryOptions:
|
||||
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||
@@ -539,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
|
||||
@@ -550,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"}
|
||||
@@ -561,75 +725,39 @@ 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"}
|
||||
|
||||
|
||||
class TestUniqueIdSourceG2:
|
||||
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
||||
class TestUniqueIdSource:
|
||||
"""t41: no unique_id is derived from another integration, at any stage.
|
||||
|
||||
def test_entity_unique_id_fallback_is_gone(self):
|
||||
from pathlib import Path
|
||||
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
|
||||
removes the entity path entirely, so the ONLY identity source left is the
|
||||
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
|
||||
"""
|
||||
|
||||
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
||||
flow_src = (base / "config_flow.py").read_text()
|
||||
# the historic renameable fallback is gone from the user step
|
||||
def test_no_entity_derived_unique_id_remains(self):
|
||||
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "_unique_id_from_atv_device" not in flow_src
|
||||
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
||||
# the documented replacement: a device-MAC derive, else no unique_id
|
||||
assert "_unique_id_from_atv_device" in flow_src
|
||||
|
||||
def test_entity_rename_never_moves_the_unique_id(self):
|
||||
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
|
||||
|
||||
# no registry lookups for another integration's devices either
|
||||
assert "device_registry" not in flow_src
|
||||
|
||||
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
|
||||
module = _form_module()
|
||||
# a registered atv entity whose device carries a MAC
|
||||
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
||||
device = SimpleNamespace(
|
||||
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
||||
)
|
||||
er_stub = module.er
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||
entities={"media_player.shield": entity}
|
||||
)
|
||||
class _DevRegistry:
|
||||
def __init__(self, by_id):
|
||||
self._by_id = by_id
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
recorded = []
|
||||
flow.async_set_unique_id = _record(recorded)
|
||||
|
||||
def async_get(self, device_id):
|
||||
return self._by_id.get(device_id)
|
||||
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
|
||||
|
||||
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
||||
|
||||
# the resolved unique_id is the MAC — not the entity_id
|
||||
uid = module._unique_id_from_atv_device(
|
||||
FakeHass("/tmp"), "media_player.shield"
|
||||
)
|
||||
|
||||
assert uid == "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
# renaming the entity does not affect the resolved value
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||
entities={"media_player.renamed": entity}
|
||||
)
|
||||
uid_renamed = module._unique_id_from_atv_device(
|
||||
FakeHass("/tmp"), "media_player.renamed"
|
||||
)
|
||||
|
||||
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
|
||||
|
||||
def test_macless_entity_yields_no_unique_id(self):
|
||||
|
||||
|
||||
module = _form_module()
|
||||
er_stub = module.er
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
|
||||
assert (
|
||||
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
|
||||
is None
|
||||
)
|
||||
assert recorded == []
|
||||
|
||||
|
||||
class TestListenerComboGone:
|
||||
@@ -816,10 +944,10 @@ class TestIpChangeSelfHealF1:
|
||||
return SimpleNamespace(
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
data={},
|
||||
options={
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_ADB_HOST: host,
|
||||
CONF_HOST: host, # t41: the entry's one address
|
||||
CONF_ADB_PORT: 5555,
|
||||
},
|
||||
runtime_data=None,
|
||||
@@ -856,11 +984,7 @@ class TestIpChangeSelfHealF1:
|
||||
|
||||
# the config-flow zeroconf path updates the entry's hosts via
|
||||
# async_update_entry — the NEXT built poller targets the NEW address
|
||||
entry.options = {
|
||||
**entry.options,
|
||||
CONF_ADB_HOST: "192.168.1.77",
|
||||
CONF_PROTOCOL_HOST: "192.168.1.77",
|
||||
}
|
||||
entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
|
||||
|
||||
second = factory()
|
||||
assert second is not None
|
||||
@@ -915,7 +1039,7 @@ class TestMacLessDiscoveryF2:
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1",
|
||||
unique_id=None, # MAC-less entry
|
||||
options={CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||
options={CONF_HOST: "192.168.1.50"},
|
||||
title="Shield",
|
||||
)
|
||||
self._holder().entries[:] = [entry]
|
||||
@@ -976,7 +1100,7 @@ class TestSeedBlockShapeF4:
|
||||
)
|
||||
|
||||
assert blocks[0]["adb_enabled"] is False
|
||||
assert blocks[0]["adb_host"] == ""
|
||||
assert blocks[0]["host"] == "shield.local" # t41: one address field
|
||||
|
||||
def test_adb_block_keeps_adb_on(self):
|
||||
import conftest
|
||||
@@ -1031,8 +1155,7 @@ class TestZeroconfDiscovery:
|
||||
assert result["step_id"] == "user"
|
||||
assert flow._discovered_host == "192.168.1.50"
|
||||
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
||||
assert defaults[CONF_ADB_HOST] == "192.168.1.50"
|
||||
assert defaults[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||
assert defaults[CONF_HOST] == "192.168.1.50"
|
||||
# ...and a discovery-driven form says the address is already known
|
||||
note = result["description_placeholders"]["discovery_note"]
|
||||
assert "Discovered Shield at 192.168.1.50" in note
|
||||
@@ -1056,7 +1179,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.1"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1067,9 +1190,8 @@ class TestZeroconfDiscovery:
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
# the hosts moved to the discovery address
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||
assert entry.options["adb_host"] == "192.168.1.50"
|
||||
# t41: the ONE address field moved to the discovery address
|
||||
assert entry.options[CONF_HOST] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||
@@ -1078,14 +1200,10 @@ class TestZeroconfDiscovery:
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
# both hosts already hold the discovered address: nothing changes
|
||||
# the address already holds the discovered value: nothing changes
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "192.168.1.50",
|
||||
"adb_host": "192.168.1.50",
|
||||
},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1099,8 +1217,24 @@ class TestZeroconfDiscovery:
|
||||
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
||||
assert hass.config_entries.reloaded_entry_ids == []
|
||||
|
||||
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path):
|
||||
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery."""
|
||||
async def test_the_per_host_heal_cases_collapsed_into_one_rule(self):
|
||||
"""t41: the blank / self-heal / deliberate-override cases are gone.
|
||||
|
||||
They only existed because the address was stored twice (t21 F2). With one
|
||||
field the discovery address simply IS the entry's address, so this pins
|
||||
the simplification: a second address field cannot quietly come back.
|
||||
"""
|
||||
source = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "CONF_ADB_HOST" not in source
|
||||
assert "CONF_PROTOCOL_HOST" not in source
|
||||
|
||||
async def test_an_explicit_address_follows_the_device(self, tmp_path):
|
||||
"""t41: one Shield, one address — discovery is its current source.
|
||||
|
||||
The old rule kept a "deliberately different ADB host" untouched; that
|
||||
override is precisely what the single-field ruling removes.
|
||||
"""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
@@ -1108,37 +1242,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||
|
||||
result = await flow.async_step_zeroconf(
|
||||
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
# only the adb host was filled (self-heal), protocol already current:
|
||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||
assert update["adb_host"] == "192.168.1.50"
|
||||
assert update["protocol_host"] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
|
||||
"""F2: an explicit ADB override survives a protocol-host IP change."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "10.0.0.1", # stale (IP moved)
|
||||
"adb_host": "10.0.0.9", # deliberate ADB override
|
||||
},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1150,8 +1254,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
||||
assert update["adb_host"] == "10.0.0.9" # override untouched
|
||||
assert update[CONF_HOST] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
|
||||
@@ -1169,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",
|
||||
}
|
||||
@@ -1182,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"}
|
||||
@@ -1192,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(
|
||||
@@ -1203,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"}
|
||||
|
||||
@@ -1220,7 +1328,6 @@ class TestAppNameMapResolution:
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = "" # standalone
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||
@@ -1235,7 +1342,6 @@ class TestAppNameMapResolution:
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = ""
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("com.google.android.youtube.tv")
|
||||
@@ -1243,23 +1349,17 @@ class TestAppNameMapResolution:
|
||||
|
||||
assert player.app_name == "com.google.android.youtube.tv"
|
||||
|
||||
async def test_entity_tier_serves_when_no_higher_tier_matches(self):
|
||||
"""P5-1: the entity tier is below map/manifest — it serves when the
|
||||
package has no explicit override and no manifest label yet."""
|
||||
from types import SimpleNamespace as SimpleNS
|
||||
|
||||
async def test_cached_manifest_label_is_the_second_tier(self):
|
||||
"""P5-1: below the explicit map sits the device manifest label."""
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {} # no override
|
||||
player._androidtv_entity = "media_player.atv"
|
||||
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
||||
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||
manager = player._manager
|
||||
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
|
||||
|
||||
player._manager._on_current_app("ar.tvplayer.tv")
|
||||
manager._on_current_app("ar.tvplayer.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# no cached label yet → falls through to the entity tier
|
||||
assert player.app_name == "ATV Tivi"
|
||||
assert player.app_name == "TiviMate"
|
||||
Reference in new issue
Block a user