Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c588a7c66b | ||
|
|
fd62d850b7 | ||
|
|
824d6192a1 | ||
|
|
60fb4f7557 | ||
|
|
bd72ae3170 | ||
|
|
37b2accfda | ||
|
|
5ede4fe722 | ||
|
|
e11dccf2c8 | ||
|
|
7a0152e6b5 | ||
|
|
186e5c1935 | ||
|
|
08b97fde73 | ||
|
|
a6cc56feb4 | ||
|
|
73e886dafe | ||
|
|
bbd98ebc3f |
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`
|
# Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N)
|
||||||
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
|
# for further releases in the same UTC day. The decision lives in
|
||||||
# manifest to force a specific release, e.g. after a meaningful change); otherwise
|
# scripts/determine-release-version.sh so it can be tested locally:
|
||||||
# the version is today's date (UTC), so a plain push to master publishes the
|
# the manifest `version` is untagged -> that version (bump it to force one)
|
||||||
# day's release. Repeated pushes on the same day are deduped by tag existence.
|
# 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/
|
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
|
||||||
# nvidia_shield/) so the integration is installable manually from any release.
|
# nvidia_shield/) so the integration is installable manually from any release.
|
||||||
@@ -28,15 +37,28 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
permissions:
|
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
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
ZIP_NAME: nvidia_shield.zip
|
ZIP_NAME: nvidia_shield.zip
|
||||||
|
MANIFEST: custom_components/nvidia_shield/manifest.json
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -45,61 +67,115 @@ jobs:
|
|||||||
ref: master
|
ref: master
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Determine release version
|
- name: Determine the release version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
|
# VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT
|
||||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
# is set inside a step, to this job's outputs too. RELEASE=no means the
|
||||||
# An unreleased manifest version wins over today's date.
|
# version already exists: tag and publish nothing.
|
||||||
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
|
scripts/determine-release-version.sh
|
||||||
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
|
|
||||||
|
|
||||||
- name: Create tag
|
- name: Write the version back into the manifest
|
||||||
if: steps.version.outputs.exists == 'no'
|
id: bump
|
||||||
|
if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git config user.name "gitea-actions"
|
git config user.name "gitea-actions"
|
||||||
git config user.email "actions@notexpectedyet.com"
|
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
|
- name: Build the release zip
|
||||||
if: steps.version.outputs.exists == 'no'
|
if: steps.version.outputs.RELEASE == 'yes'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd custom_components
|
cd custom_components
|
||||||
zip -r "../${ZIP_NAME}" nvidia_shield
|
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||||
|
|
||||||
- name: Publish the release
|
- name: Publish the release
|
||||||
if: steps.version.outputs.exists == 'no'
|
if: steps.version.outputs.RELEASE == 'yes'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.TAG }}
|
||||||
|
BUMP: ${{ steps.version.outputs.BUMP }}
|
||||||
SERVER: ${{ github.server_url }}
|
SERVER: ${{ github.server_url }}
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Notes: subject lines of the commits in the released range.
|
# Notes: subject lines of the commits in the released range — the
|
||||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
# commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
|
||||||
if [ -n "$PREV" ]; then
|
# at the bot's own commit, so the range has to stop at its parent or every
|
||||||
RANGE="${PREV}..${TAG}"
|
# 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
|
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
|
fi
|
||||||
{
|
{
|
||||||
echo "Release ${TAG}"
|
echo "Release ${TAG}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Quality gate: lint + unit tests.
|
# Quality gate: lint + unit tests + the HA API-drift sweep.
|
||||||
#
|
#
|
||||||
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
||||||
# vendored ha_adb_common helpers without installing Home Assistant — see
|
# vendored ha_adb_common helpers without installing Home Assistant — see
|
||||||
@@ -6,6 +6,11 @@
|
|||||||
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
||||||
# firmware captures live in tests/fixtures
|
# firmware captures live in tests/fixtures
|
||||||
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
|
# (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.
|
||||||
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
@@ -21,10 +26,89 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install test tooling
|
- name: Install test tooling
|
||||||
run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Try the plain install first: it succeeds on images whose Python is
|
||||||
|
# not externally managed (and on pip < 23, which has no PEP 668
|
||||||
|
# enforcement at all). When the runner image DOES mark its Python as
|
||||||
|
# externally managed — Debian/Ubuntu based Gitea act images do — pip
|
||||||
|
# refuses with externally-managed-environment and we retry with the
|
||||||
|
# sanctioned override. That override is safe here specifically
|
||||||
|
# because the job runs in a disposable container, and because the
|
||||||
|
# flag only exists on pip >= 23.0.1, the same version that raises
|
||||||
|
# the refusal in the first place.
|
||||||
|
#
|
||||||
|
# Tools are pinned to the versions the suite was validated against so
|
||||||
|
# a newer ruff rule set cannot redden the gate on unrelated commits;
|
||||||
|
# androidtvremote2 tracks the manifest floor so CI exercises the same
|
||||||
|
# dependency users install.
|
||||||
|
PKGS="pytest~=9.1 pytest-asyncio~=1.4 ruff~=0.17.0 'androidtvremote2>=0.2.0'"
|
||||||
|
if ! eval python3 -m pip install --quiet $PKGS; then
|
||||||
|
echo "system Python is externally managed; retrying with --break-system-packages"
|
||||||
|
eval python3 -m pip install --quiet --break-system-packages $PKGS
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: ruff check custom_components tests
|
run: ruff check custom_components tests
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: python3 -m pytest tests -q
|
run: python3 -m pytest tests -q
|
||||||
|
|
||||||
|
# Resolve every Home-Assistant API use in our production modules against a
|
||||||
|
# real install: imports, keyword arguments and await/sync mismatches. The
|
||||||
|
# sweep is repo-owned (tests/ha_api_drift.py) and skips cleanly when
|
||||||
|
# Home Assistant is absent, so it is inert in the job above and live here.
|
||||||
|
ha-api-drift:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Provision the interpreter Home Assistant needs
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# Home Assistant 2026.10.1 declares Requires-Python >= 3.14.2 (PyPI
|
||||||
|
# metadata; tests/ha_api_drift.py holds our copy of that number and the
|
||||||
|
# drift suite pins it against the installed distribution), so the
|
||||||
|
# runner's own python3 cannot install the version this gate must run
|
||||||
|
# against. This resolves the newest 3.14.x patch; the guard below
|
||||||
|
# refuses anything still short of the floor rather than letting the
|
||||||
|
# install fail halfway through.
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
python3 - <<'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]}."
|
||||||
|
)
|
||||||
|
print(f"Fix: raise actions/setup-python in this job to a >= {needed} build.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Install Home Assistant + test tooling
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Same PEP 668 dance as the test job (see the note there). The
|
||||||
|
# version floor tracks what we validate against; a resolver failure
|
||||||
|
# here is a real signal (Python too old), not something to swallow.
|
||||||
|
PKGS="pytest~=9.1 pytest-asyncio~=1.4 'homeassistant>=2026.10'"
|
||||||
|
if ! eval python3 -m pip install --quiet $PKGS; then
|
||||||
|
echo "system Python is externally managed; retrying with --break-system-packages"
|
||||||
|
eval python3 -m pip install --quiet --break-system-packages $PKGS
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: HA API-drift sweep
|
||||||
|
run: python3 -m pytest tests/test_ha_api_drift.py -q
|
||||||
@@ -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`
|
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
||||||
integrations.
|
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
|
> 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/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||||||
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||||||
@@ -79,10 +81,15 @@ 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 6–7 plus
|
||||||
the audio hint are what keep it visible.
|
the audio hint are what keep it visible.
|
||||||
|
|
||||||
**Standalone setup** accepts any one of the three state sources — the Android
|
**Standalone setup** offers both state sources at once — the Nvidia Remote
|
||||||
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
|
Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
|
||||||
media-session mode (ADB enabled + host). The `no_state_source` form error
|
Shield's address pre-filled when discovery found it. Either can be turned off,
|
||||||
fires only when none of the three is present; ADB-only setups are valid.
|
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
|
### Channel entry options
|
||||||
|
|
||||||
@@ -96,17 +103,32 @@ behavior). Both are consumed by `play_media` with
|
|||||||
|
|
||||||
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
||||||
interactive setup flow — nothing is created without the user walking the
|
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
|
```yaml
|
||||||
nvidia_shield:
|
nvidia_shield:
|
||||||
- name: Living Room
|
- name: Living Room # the entry's title (default: NVIDIA Shield)
|
||||||
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
|
host: 10.0.0.31 # the Shield's IP — the one address field
|
||||||
adb_port: 5555
|
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
|
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
|
||||||
the flow). Blocks without protocol/adb hosts are ignored with a warning.
|
(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
|
Duplicate guards: a block matching an existing entry's host (or its
|
||||||
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||||
host. Removing the YAML block creates or deletes nothing — entries live
|
host. Removing the YAML block creates or deletes nothing — entries live
|
||||||
@@ -126,24 +148,76 @@ documented, opt-in escape hatch for debugging and automation, registered at
|
|||||||
setup and unregistered when the last participating entity unloads. It
|
setup and unregistered when the last participating entity unloads. It
|
||||||
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
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
|
This fork replaces the old `androidtv` media player, the `androidtv_remote`
|
||||||
templates the fork replaces are retired by the ladder above: the media player
|
integration and the surrounding YAML glue — there is nothing to keep alongside
|
||||||
entity already carries the DAG attributes (`media_dag_running_apps` with
|
it: the entry's own two transports cover the whole device.
|
||||||
foreground/last-known enrichment, `media_dag_now_playing` with its source
|
|
||||||
tag) that media-zone orchestration consumed from those sensors. See the
|
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
|
||||||
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
|
2. **Remove the old platform YAML** — the `media_player:` blocks using
|
||||||
retirement mapping and the universal-player glue it replaces.
|
`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` / `idle` / `off`) |
|
||||||
|
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
|
||||||
|
| `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
|
## 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)
|
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
|
||||||
run the release workflow (`.github/workflows/release.yml`):
|
run the release workflow (`.gitea/workflows/release.yml`):
|
||||||
- A plain push publishes the current day's release (same-day pushes are deduped).
|
- A plain push publishes today's release. If that date is already tagged it takes
|
||||||
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
|
the lowest free `.N`, so a gap left by an earlier failure is reused rather than
|
||||||
specific version's release before the date fallback kicks in.
|
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
|
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
|
||||||
installs. (No automatic mirroring — see the HACS note below.)
|
installs. (No automatic mirroring — see the HACS note below.)
|
||||||
|
|
||||||
@@ -169,10 +243,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||||
2. Select the Android TV Remote entity for your Shield
|
2. Give it a name and its address — the Shield's IP, pre-filled when discovery
|
||||||
3. Enable ADB if you want now-playing from all apps
|
found the device
|
||||||
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
3. Both transports are on by default: **Read state over ADB** (now-playing from
|
||||||
5. Optionally configure Jellyfin, receiver, power switch
|
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
|
### ADB Pairing
|
||||||
|
|
||||||
@@ -182,12 +261,183 @@ device page in HA.
|
|||||||
|
|
||||||
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
||||||
|
|
||||||
|
### Discovery (zeroconf)
|
||||||
|
|
||||||
|
Home Assistant can discover a Shield through the `_androidtvremote2._tcp`
|
||||||
|
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 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
|
||||||
|
a time.
|
||||||
|
|
||||||
|
So a second (or third) Shield that advertises no MAC has to be added **manually**
|
||||||
|
— Settings > Devices & Services > Add Integration > "NVIDIA Shield" — rather than
|
||||||
|
by discovery. That is a deliberate, tested contract (t37 D3), not a bug: the
|
||||||
|
helper the flow calls is a coroutine and its dedup only runs when awaited.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||||
- **Receiver + Input** — for media-zone coordination
|
- **Receiver + Input** — for media-zone coordination
|
||||||
- **Power Switch** — smart plug entity
|
- **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.
|
||||||
|
|
||||||
|
### Sources (the app list in a dropdown)
|
||||||
|
|
||||||
|
The media player's source list is your **Apps** map — the same map the Browse tree and
|
||||||
|
app-name resolution use. With no map configured it falls back to a small built-in list
|
||||||
|
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||||
|
something to 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
|
||||||
|
is what the Browse tree publishes), 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: the Browse tree still lists it under its own package id, and
|
||||||
|
`media_player.select_source` accepts that id.
|
||||||
|
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||||
|
dropdown and the Browse tree, but `app_name`, `source` and the Current App sensor
|
||||||
|
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
|
## Entities
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,22 @@ from .const import (
|
|||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_HOST,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
CONF_PROTOCOL_HOST,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ENTRY_VERSION,
|
||||||
SERVICE_ADB_COMMAND,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -50,9 +58,9 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
|||||||
"""
|
"""
|
||||||
domain_config = (config or {}).get(DOMAIN)
|
domain_config = (config or {}).get(DOMAIN)
|
||||||
for block in _yaml_seed_blocks(domain_config or []):
|
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(
|
_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>"),
|
block.get("name", "<unnamed>"),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -84,15 +92,13 @@ def _yaml_seed_blocks(domain_config) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
name = block.get("name") or DEFAULT_NAME
|
name = block.get("name") or DEFAULT_NAME
|
||||||
# t32 F4 (review rider): the seed honors the block's shape — a
|
# 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
|
# protocol-only block does NOT force ADB on. t41: the address is ONE
|
||||||
# block carries an adb host (interactive cosmetics stay in the flow).
|
# field (`host`, or a legacy protocol_host/adb_host).
|
||||||
seed = {
|
seed = {
|
||||||
"name": name,
|
"name": name,
|
||||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||||
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
|
CONF_HOST: seed_host(block),
|
||||||
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
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"):
|
if block.get("unique_id"):
|
||||||
seed["unique_id"] = str(block["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):
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||||
|
|
||||||
Falls back to the androidtv-remote host discovery (the ADB host fallback)
|
The address is the entry's own single field (t41). Nothing is derived from
|
||||||
until the user pairs, after which the explicit protocol host persists.
|
another integration.
|
||||||
"""
|
"""
|
||||||
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
host = entry_host(entry)
|
||||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
|
||||||
)
|
|
||||||
if not host:
|
if not host:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Protocol enabled for entry %s but no host resolvable — continuing "
|
"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:
|
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
|
v1 → v2 added the remote-protocol fields (ADB options carried over
|
||||||
serving as the protocol host until pairing populates it. Downgrades
|
untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
|
||||||
(version > 2) are rejected so HA offers a rollback.
|
|
||||||
|
* 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
|
return False
|
||||||
if config_entry.version < 2:
|
if config_entry.version >= ENTRY_VERSION:
|
||||||
|
return True
|
||||||
|
|
||||||
options = {**config_entry.options}
|
options = {**config_entry.options}
|
||||||
|
data = {**config_entry.data}
|
||||||
|
|
||||||
|
if config_entry.version < 2:
|
||||||
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
||||||
options.setdefault(CONF_PROTOCOL_HOST, "")
|
|
||||||
hass.config_entries.async_update_entry(
|
if not options.get(CONF_HOST):
|
||||||
config_entry, options=options, version=2
|
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(
|
_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
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host
|
from .helpers import entry_host
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,9 +53,9 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||||
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
# t41: the entry's own single address — never derived from another
|
||||||
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
# integration's entity.
|
||||||
)
|
host = entry_host(self._entry)
|
||||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
if not host:
|
if not host:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import logging
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import device_registry as dr
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
@@ -16,27 +15,32 @@ from .const import (
|
|||||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_APP_ICON_MAP,
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
CONF_CHANNEL_SEND_ENTER,
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
|
CONF_COMMAND_SCRIPTS,
|
||||||
|
CONF_HOST,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_LOGCAT_EVENTS,
|
CONF_LOGCAT_EVENTS,
|
||||||
CONF_PAIRING_CODE,
|
CONF_PAIRING_CODE,
|
||||||
CONF_POWER_ENTITY,
|
CONF_POWER_ENTITY,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
CONF_RECEIVER_INPUT,
|
CONF_RECEIVER_INPUT,
|
||||||
|
CONF_REMOTE_COMMAND_SCRIPT,
|
||||||
|
CONF_SKIP_PAIRING,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ENTRY_VERSION,
|
||||||
|
SCRIPT_COMMANDS,
|
||||||
|
SCRIPT_FIELD_PREFIX,
|
||||||
|
command_scripts,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths
|
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,34 +56,6 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
|||||||
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
||||||
|
|
||||||
|
|
||||||
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
|
|
||||||
"""A stable unique_id from the atv entity's device MAC (t29 G2).
|
|
||||||
|
|
||||||
The device registry holds CONNECTION_NETWORK_MAC for Android TV
|
|
||||||
entities configured with a host — far stronger than the renameable
|
|
||||||
entity_id. None (→ no unique_id at all, not an entity-derived
|
|
||||||
placeholder) when the entity is unregistered or carries no MAC.
|
|
||||||
"""
|
|
||||||
if not androidtv_entity:
|
|
||||||
return None
|
|
||||||
registry = er.async_get(hass)
|
|
||||||
entity = registry.entities.get(androidtv_entity)
|
|
||||||
device_id = getattr(entity, "device_id", None) if entity else None
|
|
||||||
device = (
|
|
||||||
dr.async_get(hass).async_get(device_id) if device_id else None
|
|
||||||
)
|
|
||||||
if device is None:
|
|
||||||
return None
|
|
||||||
if isinstance(dr, dict): # stub shape guard
|
|
||||||
return None
|
|
||||||
for connection in device.connections:
|
|
||||||
# connections are {connection_type, value} pairs; only a REAL mac
|
|
||||||
# value is usable (an empty placeholder is skipped)
|
|
||||||
if connection[0] == "mac" and connection[1]:
|
|
||||||
return connection[1]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
||||||
"""Auto-detect the Jellyfin media_player entity for a device."""
|
"""Auto-detect the Jellyfin media_player entity for a device."""
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
@@ -154,15 +130,34 @@ def _app_map_from_text(
|
|||||||
return app_map
|
return app_map
|
||||||
|
|
||||||
|
|
||||||
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
def _host_field_key(field: str, host: str):
|
||||||
"""User-step schema — the androidtv entity is optional (standalone).
|
"""The host-field KEY, carrying the known address as its default.
|
||||||
|
|
||||||
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
|
t33 F-A: the default MUST ride on this key. Re-declaring a bare
|
||||||
|
``vol.Optional(field)`` afterwards is a no-op that silently drops it —
|
||||||
|
voluptuous keys compare by field name, so the first (defaultless) key
|
||||||
|
object survives and the form renders blank.
|
||||||
|
"""
|
||||||
|
if host:
|
||||||
|
return vol.Optional(field, default=host)
|
||||||
|
return vol.Optional(field)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||||
|
"""User-step schema — every field belongs to THIS integration (t41).
|
||||||
|
|
||||||
|
``defaults`` pre-fills the name and the ONE address (t41) from a zeroconf
|
||||||
|
discovery event or a YAML seed hint; the ADB port is pre-filled with the
|
||||||
|
standard 5555 so it reads as an advanced setting rather than required
|
||||||
|
knowledge (t33 F-D).
|
||||||
"""
|
"""
|
||||||
defaults = defaults or {}
|
defaults = defaults or {}
|
||||||
|
host = str(defaults.get("host") or "")
|
||||||
|
port = defaults.get("port")
|
||||||
|
if not isinstance(port, int):
|
||||||
|
port = DEFAULT_ADB_PORT
|
||||||
schema_dict = {
|
schema_dict = {
|
||||||
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
||||||
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
|
||||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||||
}
|
}
|
||||||
@@ -173,102 +168,106 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
|||||||
|
|
||||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
# t41: both transports are OURS and both are ON by default — the user opts
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
# out (or skips pairing) rather than opting in. The rendered checkboxes must
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
# agree with the code's fallbacks in async_step_user / _create_entry, so all
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
# three carry True (t33 F-A: a default the schema cannot see is a bug).
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=True)] = bool
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||||
if "host" in defaults:
|
schema_dict[_host_field_key(CONF_HOST, host)] = str
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
|
||||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=True)] = bool
|
||||||
return schema_dict
|
return schema_dict
|
||||||
|
|
||||||
|
|
||||||
|
class _ClearingEntitySelector(selector.EntitySelector):
|
||||||
|
"""An entity selector that accepts a CLEARED field (t47 F1).
|
||||||
|
|
||||||
|
Clearing an optional entity field used to depend on the frontend OMITTING the
|
||||||
|
key. It does not have to: a submission can carry ``''`` or ``None``, and real
|
||||||
|
Home Assistant's EntitySelector rejects both — the reviewer's real-HA run
|
||||||
|
produced "Schema validation failed at script_turn_on" from an explicit clear.
|
||||||
|
That made clearing look like a broken form rather than a choice, on every
|
||||||
|
entity field of this page (the script selectors are the newest, not the only
|
||||||
|
ones).
|
||||||
|
|
||||||
|
Returning the empty value unchanged lets the flow read it as "no selection",
|
||||||
|
while a real entity id still goes through the parent's validation. The class
|
||||||
|
stays an EntitySelector, so the frontend still renders an entity picker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __call__(self, data):
|
||||||
|
if data is None or (isinstance(data, str) and not data.strip()):
|
||||||
|
return ""
|
||||||
|
return super().__call__(data)
|
||||||
|
|
||||||
|
|
||||||
def _build_selectors():
|
def _build_selectors():
|
||||||
"""Build reusable selector instances."""
|
"""Build reusable selector instances."""
|
||||||
return {
|
return {
|
||||||
"androidtv": selector.EntitySelector(
|
"media_player": _ClearingEntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
|
|
||||||
),
|
|
||||||
"media_player": selector.EntitySelector(
|
|
||||||
selector.EntitySelectorConfig(domain="media_player")
|
selector.EntitySelectorConfig(domain="media_player")
|
||||||
),
|
),
|
||||||
"receiver": selector.EntitySelector(
|
"receiver": _ClearingEntitySelector(
|
||||||
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
||||||
),
|
),
|
||||||
"power": selector.EntitySelector(
|
"power": _ClearingEntitySelector(
|
||||||
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
||||||
),
|
),
|
||||||
|
# t44: a script entity per command override
|
||||||
|
"script": _ClearingEntitySelector(
|
||||||
|
selector.EntitySelectorConfig(domain="script")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Nvidia Shield."""
|
"""Handle a config flow for Nvidia Shield."""
|
||||||
|
|
||||||
VERSION = 2
|
VERSION = ENTRY_VERSION
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._user_data = {}
|
self._user_data = {}
|
||||||
self._protocol_client = None
|
self._protocol_client = None
|
||||||
self._reauth_entry = None
|
self._reauth_entry = None
|
||||||
|
# audit G5 / t35 F1: the entry a "Reconfigure" visit targets
|
||||||
|
self._reconfigure_entry = None
|
||||||
# Zeroconf discovery state (P4-1)
|
# Zeroconf discovery state (P4-1)
|
||||||
self._discovered_host: str | None = None
|
self._discovered_host: str | None = None
|
||||||
self._discovered_name: str | None = None
|
self._discovered_name: str | None = None
|
||||||
|
# YAML-seed prefill hint (t33 F-A): feeds the same user-step prefill as
|
||||||
|
# a discovery, without claiming the address was discovered.
|
||||||
|
self._seed_host: str | None = None
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Step 1: Basic setup.
|
"""Step 1: Basic setup.
|
||||||
|
|
||||||
Standalone setup (Phase 3): any one of the three state sources is
|
We own the whole stack (t41), so the two state sources are ours: the
|
||||||
enough — the Android TV Remote entity, the Nvidia Remote Protocol
|
Nvidia Remote Protocol (pairing) and ADB media-session mode
|
||||||
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
|
(CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
|
||||||
``no_state_source`` error fires only when none of them is present.
|
neither is enabled.
|
||||||
"""
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
has_source = bool(user_input.get(CONF_PROTOCOL_ENABLED)) or bool(
|
||||||
|
user_input.get(CONF_ADB_ENABLED)
|
||||||
has_source = (
|
|
||||||
bool(androidtv_entity)
|
|
||||||
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
|
|
||||||
or bool(user_input.get(CONF_ADB_ENABLED))
|
|
||||||
)
|
)
|
||||||
if not has_source:
|
if not has_source:
|
||||||
errors["base"] = "no_state_source"
|
errors["base"] = "no_state_source"
|
||||||
sel = _build_selectors()
|
return self._show_user_form(errors)
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=vol.Schema(_user_schema_dict(sel)),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
# t29 G2: the androidtv entity_id is a renameable reference —
|
# t41: nothing here derives a unique_id from another integration.
|
||||||
# never a unique_id. A sqlite/registry MAC is preferred (the
|
# The entry's identity is the protocol MAC learned during pairing
|
||||||
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
|
# (`async_set_unique_id(mac)` in the pairing step) or the config
|
||||||
# resolvable MAC the entry gets NO unique_id (the same
|
# entry itself — never a renameable entity id (t29 G2's
|
||||||
# uniqueness-less semantics _async_handle_discovery_without_
|
# atv-device derivation went with the entity path).
|
||||||
# unique_id applies to MAC-less discovery) — renames can never
|
|
||||||
# break uniqueness.
|
|
||||||
fallback_unique_id = _unique_id_from_atv_device(
|
|
||||||
self.hass, androidtv_entity
|
|
||||||
)
|
|
||||||
if fallback_unique_id:
|
|
||||||
await self.async_set_unique_id(fallback_unique_id)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
|
|
||||||
# Auto-detect Jellyfin if not manually specified
|
# Auto-detect Jellyfin from the names setup already knows: the
|
||||||
|
# entry name the user typed, or the device name from discovery.
|
||||||
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||||
if not jellyfin:
|
if not jellyfin:
|
||||||
device_name = user_input.get("name", DEFAULT_NAME)
|
for name in (user_input.get("name", DEFAULT_NAME), self._discovered_name):
|
||||||
atv_state = (
|
|
||||||
self.hass.states.get(androidtv_entity)
|
|
||||||
if androidtv_entity
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
|
||||||
|
|
||||||
for name in [device_name, atv_name]:
|
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
found = _find_jellyfin_entity(self.hass, name)
|
found = _find_jellyfin_entity(self.hass, name)
|
||||||
@@ -279,17 +278,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": user_input.get("name", DEFAULT_NAME),
|
"name": user_input.get("name", DEFAULT_NAME),
|
||||||
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
|
||||||
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||||
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||||
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
|
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
|
||||||
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
|
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, True),
|
||||||
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
CONF_HOST: str(user_input.get(CONF_HOST) or "").strip(),
|
||||||
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
|
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
|
||||||
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# If ADB is enabled, go to pairing step
|
# If ADB is enabled, go to pairing step
|
||||||
@@ -301,21 +298,48 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
|
|
||||||
|
return self._show_user_form(errors)
|
||||||
|
|
||||||
sel = _build_selectors()
|
def _user_defaults(self) -> dict:
|
||||||
|
"""Defaults for the user step: whatever setup already knows.
|
||||||
|
|
||||||
defaults = {}
|
A zeroconf discovery (or a YAML seed hint) supplies the address; the
|
||||||
if self._discovered_host:
|
port keeps a seed's hint instead of being flattened to the standard
|
||||||
|
value. This is the ONLY place the known address enters the form — the
|
||||||
|
schema keys carry these values as their defaults (t33 F-A).
|
||||||
|
"""
|
||||||
defaults = {
|
defaults = {
|
||||||
"name": self._discovered_name or DEFAULT_NAME,
|
"name": self._discovered_name
|
||||||
"host": self._discovered_host,
|
or self._user_data.get("name")
|
||||||
|
or DEFAULT_NAME,
|
||||||
}
|
}
|
||||||
schema_dict = _user_schema_dict(sel, defaults)
|
host = self._discovered_host or self._seed_host
|
||||||
|
if host:
|
||||||
|
defaults["host"] = host
|
||||||
|
port = self._user_data.get(CONF_ADB_PORT)
|
||||||
|
if isinstance(port, int):
|
||||||
|
defaults["port"] = port
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
def _show_user_form(self, errors=None):
|
||||||
|
"""Render the user step with the known address actually applied.
|
||||||
|
|
||||||
|
t33 F-A2: a discovery-driven form says so in its description, so a
|
||||||
|
pre-filled host can never be misread as "unknown".
|
||||||
|
"""
|
||||||
|
note = ""
|
||||||
|
if self._discovered_host:
|
||||||
|
note = (
|
||||||
|
f"Discovered Shield at {self._discovered_host} — its address "
|
||||||
|
"is already filled in below. "
|
||||||
|
)
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(schema_dict),
|
data_schema=vol.Schema(
|
||||||
errors=errors,
|
_user_schema_dict(_build_selectors(), self._user_defaults())
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
|
description_placeholders={"discovery_note": note},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_zeroconf(self, discovery_info):
|
async def async_step_zeroconf(self, discovery_info):
|
||||||
@@ -344,22 +368,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
for entry in self._async_current_entries():
|
for entry in self._async_current_entries():
|
||||||
if getattr(entry, "unique_id", None) != mac:
|
if getattr(entry, "unique_id", None) != mac:
|
||||||
continue
|
continue
|
||||||
# Known device — per-host updates (t21 F2): each host is
|
# Known device — IP change (t21 F2, collapsed by t41): there is
|
||||||
# tracked independently against the discovery address.
|
# ONE address field now, so the discovery address simply IS the
|
||||||
# - a blank (auto-detect) host self-heals from the address;
|
# entry's address. The per-host dance (blank self-heal / stale
|
||||||
# - a stale protocol host is the IP change and is updated;
|
# protocol host / deliberately different ADB host) only existed
|
||||||
# - a deliberately-different ADB host (an explicit override
|
# because the address was stored twice; it went with the
|
||||||
# that differs from both the address and the stored
|
# duplication.
|
||||||
# protocol host) is never clobbered.
|
|
||||||
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
|
|
||||||
adb_host = entry.options.get(CONF_ADB_HOST, "")
|
|
||||||
options = dict(entry.options)
|
options = dict(entry.options)
|
||||||
if protocol_host != host:
|
if entry_host(entry) != host:
|
||||||
options[CONF_PROTOCOL_HOST] = host
|
options[CONF_HOST] = host
|
||||||
if not adb_host or adb_host == protocol_host:
|
|
||||||
# blank (self-heal) or auto-synced alongside the protocol
|
|
||||||
# host — safe to follow the device address on discovery
|
|
||||||
options[CONF_ADB_HOST] = host
|
|
||||||
if options != entry.options:
|
if options != entry.options:
|
||||||
# t29 G3a: the deprecated update+schedule-reload combo is
|
# t29 G3a: the deprecated update+schedule-reload combo is
|
||||||
# gone (2026.12 error) — the entry updates ONLY here; the
|
# gone (2026.12 error) — the entry updates ONLY here; the
|
||||||
@@ -371,42 +388,42 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_abort(reason="already_configured")
|
return self.async_abort(reason="already_configured")
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
else:
|
else:
|
||||||
# t32 F2 (review rider): MAC-less discovery cannot carry a
|
# t32 F2 / t37 D3: MAC-less discovery cannot carry a unique_id, so
|
||||||
# unique_id — HA's helper records the uniqueness-less discovery
|
# HA's own helper owns the dedup — and it MUST be awaited. It is a
|
||||||
# (the same discovery context can't stack duplicates) and the
|
# coroutine that aborts `already_configured` when the handler
|
||||||
# host guard keeps a second discovery of an already-configured
|
# already has entries, claims DEFAULT_DISCOVERY_UNIQUE_ID, and
|
||||||
# host from stacking another prefill.
|
# aborts `already_in_progress` when another flow for this handler
|
||||||
self._async_handle_discovery_without_unique_id()
|
# is running. t32 shipped the call without the await, so none of
|
||||||
for entry in self._async_current_entries():
|
# that ran ("coroutine was never awaited") and the host-equality
|
||||||
existing_host = entry.options.get(
|
# guard that used to sit here was the accidental dedup; the real
|
||||||
CONF_PROTOCOL_HOST
|
# helper is stricter (any entry for the domain, any in-flight
|
||||||
) or entry.options.get(CONF_ADB_HOST)
|
# flow), so nothing is left to do here.
|
||||||
if existing_host and existing_host == host:
|
await self._async_handle_discovery_without_unique_id()
|
||||||
return self.async_abort(reason="already_configured")
|
|
||||||
|
|
||||||
# First-time setup: pre-filled user step
|
# First-time setup: pre-filled user step
|
||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
|
|
||||||
def _create_entry(self):
|
def _create_entry(self):
|
||||||
"""Create the config entry from stored user data."""
|
"""Create the config entry from stored user data.
|
||||||
|
|
||||||
|
Everything the integration needs lives in the OPTIONS (t41: one host
|
||||||
|
field among them); ``data`` stays empty because no value has to be
|
||||||
|
immutable.
|
||||||
|
"""
|
||||||
d = self._user_data
|
d = self._user_data
|
||||||
entry_data = {}
|
|
||||||
if d.get(CONF_ANDROIDTV_ENTITY):
|
|
||||||
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=d["name"],
|
title=d["name"],
|
||||||
data=entry_data,
|
data={},
|
||||||
options={
|
options={
|
||||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
|
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
|
||||||
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
|
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
|
||||||
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
|
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, True),
|
||||||
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
CONF_HOST: d.get(CONF_HOST, ""),
|
||||||
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
|
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, True),
|
||||||
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,19 +433,34 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
return await self.async_step_protocol_pair()
|
return await self.async_step_protocol_pair()
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
|
|
||||||
def _protocol_host(self) -> str | None:
|
|
||||||
"""Protocol host: explicit override, else the ADB host fallback."""
|
def _discard_protocol_client(self) -> None:
|
||||||
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
"""Drop a pairing client whose session became void (host corrected)."""
|
||||||
|
client = self._protocol_client
|
||||||
|
self._protocol_client = None
|
||||||
|
if client is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
async def _start_protocol_pairing(self):
|
async def _start_protocol_pairing(self):
|
||||||
"""Build a throwaway protocol client and make the Shield show its PIN.
|
"""Build a throwaway protocol client and make the Shield show its PIN.
|
||||||
|
|
||||||
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
||||||
helpers.py pattern) and are shared between pairing and runtime.
|
helpers.py pattern) and are shared between pairing and runtime. Returns
|
||||||
|
``(client, None)`` or ``(None, error_key)`` — no host, a missing
|
||||||
|
``androidtvremote2`` and an unreachable Shield are all keys the form can
|
||||||
|
show, never an exception out of the step.
|
||||||
|
|
||||||
|
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below,
|
||||||
|
and the protocol transport is ON by default, so an asleep, rebooting or
|
||||||
|
mis-addressed Shield is the ordinary case rather than an edge one. Left
|
||||||
|
uncaught it escaped the config flow entirely, and the protocol_pair form
|
||||||
|
— the address field AND the "continue without pairing" escape — never
|
||||||
|
rendered.
|
||||||
"""
|
"""
|
||||||
host = self._protocol_host()
|
host = self._host()
|
||||||
if not host:
|
if not host:
|
||||||
return None
|
return None, "protocol_no_host"
|
||||||
certfile, keyfile = await self.hass.async_add_executor_job(
|
certfile, keyfile = await self.hass.async_add_executor_job(
|
||||||
protocol_cert_paths, self.hass
|
protocol_cert_paths, self.hass
|
||||||
)
|
)
|
||||||
@@ -438,32 +470,123 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
certfile=certfile,
|
certfile=certfile,
|
||||||
keyfile=keyfile,
|
keyfile=keyfile,
|
||||||
)
|
)
|
||||||
|
if client is None:
|
||||||
|
# build_protocol_client already logged the missing library;
|
||||||
|
# pairing cannot start, and the form still offers the skip path
|
||||||
|
return None, "protocol_unavailable"
|
||||||
|
try:
|
||||||
await client.async_generate_cert_if_missing()
|
await client.async_generate_cert_if_missing()
|
||||||
await client.async_start_pairing()
|
await client.async_start_pairing()
|
||||||
return client
|
except Exception as err: # noqa: BLE001 - the library's error IS the finding
|
||||||
|
_LOGGER.warning(
|
||||||
|
"protocol pairing could not start at %s (%s: %s) — showing the "
|
||||||
|
"form so the address can be corrected or pairing skipped",
|
||||||
|
host,
|
||||||
|
type(err).__name__,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
client.disconnect()
|
||||||
|
return None, "protocol_unreachable"
|
||||||
|
return client, None
|
||||||
|
|
||||||
|
async def _async_begin_protocol_pairing(self):
|
||||||
|
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
|
||||||
|
return await self._start_protocol_pairing()
|
||||||
|
|
||||||
|
def _show_protocol_pair_form(self, errors=None):
|
||||||
|
"""protocol_pair form: the PIN, the address, and the skip escape.
|
||||||
|
|
||||||
|
t33 F-B/F-C: the host field lives on THIS form, so an unresolvable
|
||||||
|
address is fixable where the error is shown (the user step is gone),
|
||||||
|
and the skip option keeps the step from being a dead end.
|
||||||
|
"""
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="protocol_pair",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_PAIRING_CODE): str,
|
||||||
|
_host_field_key(CONF_HOST, self._host()): str,
|
||||||
|
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_skip_protocol_pairing(self):
|
||||||
|
"""Continue without the protocol transport (t33 F-B, passive mode).
|
||||||
|
|
||||||
|
The transport is switched OFF rather than left half-paired: an
|
||||||
|
unpaired protocol client fails auth on its first connect and would
|
||||||
|
only bounce the user into the reauth flow — a fresh dead end.
|
||||||
|
"""
|
||||||
|
self._discard_protocol_client()
|
||||||
|
self._user_data[CONF_PROTOCOL_ENABLED] = False
|
||||||
|
if self._reauth_entry is not None:
|
||||||
|
# an existing entry cannot be re-created: leave it untouched and
|
||||||
|
# say so instead of pretending the re-pair worked
|
||||||
|
self._reauth_entry = None
|
||||||
|
return self.async_abort(reason="reauth_skipped")
|
||||||
|
if self._reconfigure_entry is not None:
|
||||||
|
# same for a Reconfigure visit: nothing changed, the entry keeps
|
||||||
|
# its options and can be reconfigured again at any time
|
||||||
|
self._reconfigure_entry = None
|
||||||
|
return self.async_abort(reason="reconfigure_skipped")
|
||||||
|
return self._create_entry()
|
||||||
|
|
||||||
|
async def _async_restart_protocol_pairing(self, errors: dict):
|
||||||
|
"""Start a fresh pairing session and render the form for its code.
|
||||||
|
|
||||||
|
Called on the first visit, after a host correction, and when the user
|
||||||
|
submits without a code: each is a request for the Shield to show a new
|
||||||
|
code, and none of them may leave the step stuck.
|
||||||
|
"""
|
||||||
|
self._discard_protocol_client()
|
||||||
|
client, error = await self._async_begin_protocol_pairing()
|
||||||
|
if error:
|
||||||
|
errors["base"] = error
|
||||||
|
else:
|
||||||
|
self._protocol_client = client
|
||||||
|
return self._show_protocol_pair_form(errors)
|
||||||
|
|
||||||
async def async_step_protocol_pair(self, user_input=None):
|
async def async_step_protocol_pair(self, user_input=None):
|
||||||
"""Pair with the remote protocol — the PIN is displayed on the Shield."""
|
"""Pair with the remote protocol — the PIN is displayed on the Shield.
|
||||||
|
|
||||||
|
Never a dead end (t33 F-B): a corrected address restarts pairing right
|
||||||
|
here, submitting without a code asks for a fresh one, and
|
||||||
|
``skip_pairing`` finishes the entry in passive mode.
|
||||||
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
# First visit: proactively start pairing so the PIN shows on the
|
# First visit: proactively start pairing so the PIN shows on the
|
||||||
# Shield while the form is open.
|
# Shield while the form is open.
|
||||||
client = await self._start_protocol_pairing()
|
return await self._async_restart_protocol_pairing(errors)
|
||||||
if client is None:
|
|
||||||
errors["base"] = "protocol_no_host"
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
else:
|
return await self._async_skip_protocol_pairing()
|
||||||
self._protocol_client = client
|
|
||||||
else:
|
typed_host = str(user_input.get(CONF_HOST) or "").strip()
|
||||||
|
if typed_host and typed_host != self._host():
|
||||||
|
# A host typed HERE is authoritative — the user step is gone, so
|
||||||
|
# the session started against the old address is dropped and the
|
||||||
|
# Shield is asked for a NEW code at the corrected address (t35 F1).
|
||||||
|
self._user_data[CONF_HOST] = typed_host
|
||||||
|
self._discard_protocol_client()
|
||||||
|
|
||||||
|
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
|
||||||
|
if self._protocol_client is None or not code:
|
||||||
|
# No live session, or no code typed: (re)start pairing so the
|
||||||
|
# Shield shows a code for the address currently on the form.
|
||||||
|
return await self._async_restart_protocol_pairing(errors)
|
||||||
|
|
||||||
client = self._protocol_client
|
client = self._protocol_client
|
||||||
code = user_input.get(CONF_PAIRING_CODE, "")
|
|
||||||
if client is None or not code:
|
|
||||||
errors["base"] = "protocol_invalid_code"
|
|
||||||
else:
|
|
||||||
try:
|
try:
|
||||||
await client.async_finish_pairing(code)
|
await client.async_finish_pairing(code)
|
||||||
except Exception:
|
except Exception:
|
||||||
errors["base"] = "protocol_pair_failed"
|
errors["base"] = "protocol_pair_failed"
|
||||||
else:
|
return self._show_protocol_pair_form(errors)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_name, mac = await client.async_get_name_and_mac()
|
_name, mac = await client.async_get_name_and_mac()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -471,19 +594,20 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
client.disconnect()
|
client.disconnect()
|
||||||
self._protocol_client = None
|
self._protocol_client = None
|
||||||
if mac:
|
target = self._reauth_entry or self._reconfigure_entry
|
||||||
|
if mac and mac != getattr(target, "unique_id", None):
|
||||||
|
# A MAC that is already configured must never be adopted silently.
|
||||||
|
# A reauth/reconfigure flow already OWNS an entry, and
|
||||||
|
# _abort_if_unique_id_configured looks the domain up by unique_id —
|
||||||
|
# it would abort on that very entry when the MAC is unchanged
|
||||||
|
# (t35 F1: re-pairing an entry used to abort as "already_configured").
|
||||||
await self.async_set_unique_id(mac)
|
await self.async_set_unique_id(mac)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
if self._reauth_entry is not None:
|
if self._reauth_entry is not None:
|
||||||
return self._finish_reauth()
|
return self._finish_reauth()
|
||||||
|
if self._reconfigure_entry is not None:
|
||||||
|
return self._finish_reconfigure(mac)
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
return self.async_show_form(
|
|
||||||
step_id="protocol_pair",
|
|
||||||
data_schema=vol.Schema({
|
|
||||||
vol.Required(CONF_PAIRING_CODE): str,
|
|
||||||
}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_import(self, data):
|
async def async_step_import(self, data):
|
||||||
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
||||||
@@ -496,19 +620,17 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
"""
|
"""
|
||||||
block = dict(data or {})
|
block = dict(data or {})
|
||||||
name = block.get("name") or DEFAULT_NAME
|
name = block.get("name") or DEFAULT_NAME
|
||||||
|
# the block's address comes from `host`, or from the legacy keys
|
||||||
|
block_host = seed_host(block)
|
||||||
seed_unique_id = block.get("unique_id")
|
seed_unique_id = block.get("unique_id")
|
||||||
if not seed_unique_id:
|
if not seed_unique_id:
|
||||||
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
seed_unique_id = f"yaml:{name}:{block_host or 'nohost'}"
|
||||||
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
|
|
||||||
|
|
||||||
# duplicate guards
|
# duplicate guards
|
||||||
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
|
||||||
for existing in self._async_current_entries():
|
for existing in self._async_current_entries():
|
||||||
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
|
ehost = entry_host(existing)
|
||||||
CONF_ADB_HOST
|
|
||||||
)
|
|
||||||
if existing.unique_id == seed_unique_id or (
|
if existing.unique_id == seed_unique_id or (
|
||||||
yhost and ehost == yhost
|
block_host and ehost == block_host
|
||||||
):
|
):
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"nvidia_shield YAML seed for %r matches an existing "
|
"nvidia_shield YAML seed for %r matches an existing "
|
||||||
@@ -524,15 +646,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
# block actually carries an adb host.
|
# block actually carries an adb host.
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": name,
|
"name": name,
|
||||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
# t32 F4's shape rule on the t41 keys: an address names the Shield,
|
||||||
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
|
# and an explicit adb_enabled says otherwise
|
||||||
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||||
# protocol prefill hints ONLY (pairing stays interactive)
|
CONF_HOST: block_host,
|
||||||
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
|
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||||
}
|
}
|
||||||
self._discovered_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
|
# t33 F-A: the seed host feeds the same user-step prefill as a
|
||||||
CONF_ADB_HOST
|
# zeroconf discovery, but WITHOUT the discovery announcement — the
|
||||||
]
|
# address came from configuration.yaml, not from the network.
|
||||||
|
self._seed_host = block_host
|
||||||
return await self.async_step_user(None)
|
return await self.async_step_user(None)
|
||||||
|
|
||||||
async def async_step_reauth(self, entry_data):
|
async def async_step_reauth(self, entry_data):
|
||||||
@@ -553,46 +676,150 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": entry.title,
|
"name": entry.title,
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
CONF_HOST: entry_host(entry),
|
||||||
}
|
}
|
||||||
return await self.async_step_protocol_pair() # first visit re-pairs
|
return await self.async_step_protocol_pair() # first visit re-pairs
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reauth_confirm", data_schema=vol.Schema({})
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(self, user_input=None):
|
||||||
|
"""Re-run protocol pairing for an existing entry (audit G5, t35 F1).
|
||||||
|
|
||||||
|
Home Assistant offers this as the entry's "Reconfigure" action — the
|
||||||
|
discoverable way back for an entry whose protocol transport is off
|
||||||
|
(typically the "continue without protocol pairing" outcome): the
|
||||||
|
address is editable, the Shield shows a PIN, and the entry's options
|
||||||
|
are updated + reloaded.
|
||||||
|
"""
|
||||||
|
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||||
|
if entry is None:
|
||||||
|
# HA always links the entry; a missing one gets a plain abort
|
||||||
|
# instead of an exception
|
||||||
|
return self.async_abort(reason="reconfigure_entry_missing")
|
||||||
|
self._reconfigure_entry = entry
|
||||||
|
# Seed the flow from the entry so the pairing step offers the address
|
||||||
|
# the entry already knows (and _create_entry is never reached here).
|
||||||
|
self._user_data = {
|
||||||
|
"name": entry.title,
|
||||||
|
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||||||
|
CONF_HOST: entry_host(entry),
|
||||||
|
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
}
|
||||||
|
return await self.async_step_protocol_pair()
|
||||||
|
|
||||||
|
def _finish_reconfigure(self, mac: str | None):
|
||||||
|
"""Reconfigure pairing done: update + reload + abort (G3a contract).
|
||||||
|
|
||||||
|
The protocol host persists as the address that just paired, so the
|
||||||
|
entry no longer depends on the ADB-host fallback for its transport.
|
||||||
|
HA derives the abort reason ("reconfigure_successful") from the flow
|
||||||
|
source, so this needs no integration string.
|
||||||
|
"""
|
||||||
|
entry = self._reconfigure_entry or self._get_reconfigure_entry()
|
||||||
|
updates: dict = {
|
||||||
|
"options": {
|
||||||
|
**entry.options,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_HOST: self._host(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if mac:
|
||||||
|
# a swapped device keeps the entry's identity honest
|
||||||
|
updates["unique_id"] = mac
|
||||||
|
self._reconfigure_entry = None
|
||||||
|
return self.async_update_reload_and_abort(entry, **updates)
|
||||||
|
|
||||||
def _finish_reauth(self):
|
def _finish_reauth(self):
|
||||||
"""Reauth pairing done: update + reload + abort via the current API (G3a).
|
"""Reauth pairing done: update + reload + abort via the current API (G3a).
|
||||||
|
|
||||||
The deprecated update-entry + schedule-reload boomerang listener is
|
The deprecated update-entry + schedule-reload boomerang listener is
|
||||||
gone (2026.12 treats it as an error); the current config-entries
|
gone (2026.12 treats it as an error); the current config-entries
|
||||||
contract is async_update_reload_and_abort with _get_reauth_entry.
|
contract is async_update_reload_and_abort with _get_reauth_entry.
|
||||||
|
Core's kwarg is `options` (REPLACE) — there is no `options_updates`.
|
||||||
"""
|
"""
|
||||||
entry = self._reauth_entry or self._get_reauth_entry()
|
entry = self._reauth_entry or self._get_reauth_entry()
|
||||||
self.async_update_reload_and_abort(
|
self.async_update_reload_and_abort(
|
||||||
entry,
|
entry,
|
||||||
options_updates={
|
options={
|
||||||
**entry.options,
|
**entry.options,
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
CONF_HOST: self._host(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._reauth_entry = None
|
self._reauth_entry = None
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
def _resolve_adb_host(self) -> str | None:
|
def _host(self) -> str:
|
||||||
"""Resolve ADB host from explicit config or the Android TV entity."""
|
"""The Shield's address as this flow knows it (t41: ONE field).
|
||||||
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
|
||||||
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
What the user typed (or a seed carried) lives in ``_user_data``; the
|
||||||
|
discovery/seed hints stay as fallbacks for a form that was rendered with
|
||||||
|
a pre-filled field and submitted unchanged.
|
||||||
|
"""
|
||||||
|
return str(
|
||||||
|
self._user_data.get(CONF_HOST)
|
||||||
|
or self._discovered_host
|
||||||
|
or self._seed_host
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
def _show_adb_pair_form(self, errors=None):
|
||||||
|
"""adb_pair form: the address/port (fixable here) and the skip escape.
|
||||||
|
|
||||||
|
t33 F-B/F-C/F-D: the fields live on THIS form — the user step is gone
|
||||||
|
— so a missing address is fixable where the error appears, the
|
||||||
|
standard 5555 is pre-filled, and the skip option ends the dead end.
|
||||||
|
"""
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="adb_pair",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
_host_field_key(CONF_HOST, self._host()): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
default=self._user_data.get(
|
||||||
|
CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||||||
|
),
|
||||||
|
): int,
|
||||||
|
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_adb_pair(self, user_input=None):
|
async def async_step_adb_pair(self, user_input=None):
|
||||||
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
"""Step 2: ADB pairing. Approve the prompt on the Shield, then Submit.
|
||||||
|
|
||||||
|
Never a dead end (t33 F-B): the host/port are editable on the form, a
|
||||||
|
missing address is reported against the field above it, and the skip
|
||||||
|
option finishes the entry in passive mode.
|
||||||
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host = self._resolve_adb_host()
|
if user_input.get(CONF_SKIP_PAIRING):
|
||||||
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
# Continue WITHOUT pairing. ADB stays enabled on purpose: the
|
||||||
|
# manager already degrades to its passive, background-retrying
|
||||||
|
# state when no key exists, and the Pair ADB button (only
|
||||||
|
# registered for adb-enabled entries) pairs later without
|
||||||
|
# re-adding the integration.
|
||||||
|
return await self._async_after_pairing()
|
||||||
|
|
||||||
|
typed_host = str(user_input.get(CONF_HOST) or "").strip()
|
||||||
|
if typed_host:
|
||||||
|
# a host typed HERE is authoritative (the user step is gone)
|
||||||
|
self._user_data[CONF_HOST] = typed_host
|
||||||
|
|
||||||
|
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
try:
|
||||||
|
port = int(port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = DEFAULT_ADB_PORT
|
||||||
|
self._user_data[CONF_ADB_PORT] = port
|
||||||
|
|
||||||
|
host = self._host()
|
||||||
if not host:
|
if not host:
|
||||||
errors["base"] = "adb_no_host"
|
errors["base"] = "adb_no_host"
|
||||||
else:
|
else:
|
||||||
@@ -608,11 +835,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
errors["base"] = "adb_failed"
|
errors["base"] = "adb_failed"
|
||||||
|
|
||||||
return self.async_show_form(
|
return self._show_adb_pair_form(errors)
|
||||||
step_id="adb_pair",
|
|
||||||
data_schema=vol.Schema({}),
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
@@ -646,6 +869,11 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
"""Single-page options."""
|
"""Single-page options."""
|
||||||
errors = {}
|
errors = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
|
# t47 F1: a field submitted as None means "not supplied" — only an
|
||||||
|
# explicit empty value ('' or a cleared selector) clears a setting, so
|
||||||
|
# the two cannot be confused. Note the schema itself already tolerates
|
||||||
|
# the empty value (see _ClearingEntitySelector).
|
||||||
|
user_input = {key: value for key, value in user_input.items() if value is not None}
|
||||||
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
||||||
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
|
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
|
||||||
if delay is not None:
|
if delay is not None:
|
||||||
@@ -656,6 +884,23 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
delay = None
|
delay = None
|
||||||
if delay is not None and not (0.05 <= delay <= 1.0):
|
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||||
errors["base"] = "invalid_digit_delay"
|
errors["base"] = "invalid_digit_delay"
|
||||||
|
# t44: one selector per command, stored as a single dict option so
|
||||||
|
# the service layer reads one shape. Popped here because _options_dict
|
||||||
|
# is updated with the rest of user_input verbatim below.
|
||||||
|
scripts = {
|
||||||
|
command: script
|
||||||
|
for command in SCRIPT_COMMANDS
|
||||||
|
if (
|
||||||
|
script := str(
|
||||||
|
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
|
||||||
|
).strip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
# t47: one optional script for the remote entity — a plain string
|
||||||
|
# option, unlike the per-command dict above
|
||||||
|
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||||
|
if remote_script is not None:
|
||||||
|
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
|
||||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
icons: dict[str, str] = {}
|
icons: dict[str, str] = {}
|
||||||
if app_map_text is None:
|
if app_map_text is None:
|
||||||
@@ -675,6 +920,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
# truth, so a save without icons clears them (and a
|
# truth, so a save without icons clears them (and a
|
||||||
# single-column map stays valid).
|
# single-column map stays valid).
|
||||||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||||
|
# an empty dict clears every override (the editor is the truth)
|
||||||
|
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
|
||||||
return self.async_create_entry(title="", data=self._options_dict)
|
return self.async_create_entry(title="", data=self._options_dict)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
@@ -699,6 +946,18 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||||
schema_dict[recv_key] = sel["receiver"]
|
schema_dict[recv_key] = sel["receiver"]
|
||||||
|
|
||||||
|
# t44: command script overrides — the escape hatch for hardware we do not
|
||||||
|
# drive (a speaker switch owning the volume). Unset = native path as before.
|
||||||
|
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
|
||||||
|
|
||||||
|
configured_scripts = command_scripts(self._options_dict)
|
||||||
|
suggested_scripts = {}
|
||||||
|
for command in SCRIPT_COMMANDS:
|
||||||
|
field = f"{SCRIPT_FIELD_PREFIX}{command}"
|
||||||
|
schema_dict[vol.Optional(field)] = sel["script"]
|
||||||
|
if configured_scripts.get(command):
|
||||||
|
suggested_scripts[field] = configured_scripts[command]
|
||||||
|
|
||||||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||||
@@ -737,12 +996,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||||
schema_dict[pwr_key] = sel["power"]
|
schema_dict[pwr_key] = sel["power"]
|
||||||
|
|
||||||
# ADB options
|
# Transport toggles (t41: both transports are ours; the skip paths and
|
||||||
|
# this page are the way to turn either one off)
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||||
|
|
||||||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
# The ONE address field (t41), plus the ADB port
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
schema_dict[vol.Optional(CONF_HOST)] = str
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||||
|
|
||||||
# t29 G3b: suggested values come from the entry options via the
|
# t29 G3b: suggested values come from the entry options via the
|
||||||
@@ -750,7 +1011,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
data_schema=self.add_suggested_values_to_schema(
|
||||||
vol.Schema(schema_dict), self._options_dict
|
vol.Schema(schema_dict),
|
||||||
|
{**self._options_dict, **suggested_scripts},
|
||||||
),
|
),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
@@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401
|
|||||||
|
|
||||||
DOMAIN = "nvidia_shield"
|
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_ANDROIDTV_ENTITY = "androidtv_entity"
|
||||||
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
||||||
CONF_RECEIVER_ENTITY = "receiver_entity"
|
CONF_RECEIVER_ENTITY = "receiver_entity"
|
||||||
@@ -19,13 +29,15 @@ CONF_RECEIVER_INPUT = "receiver_input"
|
|||||||
CONF_POWER_ENTITY = "power_entity"
|
CONF_POWER_ENTITY = "power_entity"
|
||||||
CONF_ADB_ENABLED = "adb_enabled"
|
CONF_ADB_ENABLED = "adb_enabled"
|
||||||
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
||||||
CONF_ADB_HOST = "adb_host"
|
|
||||||
CONF_ADB_PORT = "adb_port"
|
CONF_ADB_PORT = "adb_port"
|
||||||
|
|
||||||
# Remote-protocol transport (androidtvremote2) — Phase 2
|
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||||
CONF_PROTOCOL_HOST = "protocol_host"
|
|
||||||
CONF_PAIRING_CODE = "pairing_code"
|
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
|
||||||
|
# user on a form with no way forward.
|
||||||
|
CONF_SKIP_PAIRING = "skip_pairing"
|
||||||
DEFAULT_PROTOCOL_API_PORT = 6466
|
DEFAULT_PROTOCOL_API_PORT = 6466
|
||||||
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
||||||
|
|
||||||
@@ -43,6 +55,11 @@ STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
|||||||
|
|
||||||
DEFAULT_NAME = "Nvidia Shield"
|
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
|
# Emotiva DAG attribute we read to get available inputs
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||||
|
|
||||||
@@ -143,3 +160,72 @@ SYSTEM_MEDIA_PACKAGES = [
|
|||||||
"com.android.tv.settings",
|
"com.android.tv.settings",
|
||||||
"com.nvidia.diagtools",
|
"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
|
||||||
@@ -16,8 +16,10 @@ from dataclasses import asdict, is_dataclass
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.redact import async_redact_data
|
||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
|
from .media_player import entity_available
|
||||||
|
|
||||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
_REDACTED = "**REDACTED**"
|
_REDACTED = "**REDACTED**"
|
||||||
@@ -80,22 +82,27 @@ async def async_get_config_entry_diagnostics(
|
|||||||
if isinstance(manager, ShieldConnectionManager):
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
payload["store"] = manager.store_snapshot()
|
payload["store"] = manager.store_snapshot()
|
||||||
payload["transport"] = {
|
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": {
|
"protocol": {
|
||||||
"active": manager.protocol_active,
|
"active": manager.protocol_active,
|
||||||
"available": manager.is_available,
|
"available": manager.is_available,
|
||||||
"is_on": manager.is_on,
|
"is_on": manager.is_on,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# G14 (t30): the documented helper is the primary redaction path with
|
# G14 (t30) + t37 D2: the documented helper is the primary redaction path
|
||||||
# an explicit field list; the marker scan above runs defensively after.
|
# with an explicit field list (the hosts included), and the marker scan
|
||||||
try:
|
# above runs defensively after it. It is imported at MODULE scope from
|
||||||
from homeassistant.util.diagnostic import async_redact_data
|
# homeassistant.helpers.redact — the real module; the old
|
||||||
except ImportError:
|
# `homeassistant.util.diagnostic` never existed, and the swallowed
|
||||||
async_redact_data = None
|
# ImportError turned the whole pass into a silent no-op.
|
||||||
if async_redact_data is not None:
|
return _redact(async_redact_data(payload, _TO_REDACT))
|
||||||
payload = async_redact_data(payload, _TO_REDACT)
|
|
||||||
return _redact(payload)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["async_get_config_entry_diagnostics"]
|
__all__ = ["async_get_config_entry_diagnostics"]
|
||||||
@@ -4,12 +4,13 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_HOST,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
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")
|
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||||
|
|
||||||
|
|
||||||
def discover_adb_host(
|
def entry_host(entry) -> str:
|
||||||
hass: HomeAssistant, androidtv_entity_id: str | None
|
"""The Shield's address for an entry (t41: ONE field).
|
||||||
) -> str | None:
|
|
||||||
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
|
``options`` is the source of truth; ``data`` is read as a fallback so an
|
||||||
if not androidtv_entity_id:
|
entry created by an older flow (or a hand-written seed) still resolves.
|
||||||
return None
|
"""
|
||||||
registry = er.async_get(hass)
|
options = getattr(entry, "options", None) or {}
|
||||||
entity_entry = registry.async_get(androidtv_entity_id)
|
data = getattr(entry, "data", None) or {}
|
||||||
if not entity_entry or not entity_entry.config_entry_id:
|
return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip()
|
||||||
return None
|
|
||||||
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
|
||||||
if config_entry:
|
def seed_host(block: dict) -> str:
|
||||||
return config_entry.data.get("host")
|
"""The address a YAML seed block carries (t41).
|
||||||
return None
|
|
||||||
|
``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:
|
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||||
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||||
|
|
||||||
Priority: explicit ADB host/port options > host from the AndroidTV
|
Priority: the entry's own address (t41: no other integration is consulted)
|
||||||
entity's own config entry.
|
and its ADB port option.
|
||||||
"""
|
"""
|
||||||
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
host = entry_host(entry)
|
||||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
|
||||||
)
|
|
||||||
if not host:
|
if not host:
|
||||||
return None
|
return None
|
||||||
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"adb-shell>=0.4.0",
|
"adb-shell>=0.4.0",
|
||||||
"androidtvremote2>=0.2.0"
|
"androidtvremote2>=0.2.0"
|
||||||
],
|
],
|
||||||
"version": "2026.10.08",
|
"version": "2026.10.11.2",
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
"_androidtvremote2._tcp.local."
|
"_androidtvremote2._tcp.local."
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ State architecture:
|
|||||||
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
||||||
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
||||||
- Metadata: Jellyfin attributes
|
- Metadata: Jellyfin attributes
|
||||||
- Commands: protocol key push → ADB keyevent → external androidtv_remote
|
- Commands: a configured per-command script (t44) → protocol key push → ADB
|
||||||
(the last tier is transitional; removed in Phase 4)
|
keyevent. The external androidtv_remote entity tier that used to back this is
|
||||||
|
gone — t41 removed it with the entity — so a command has exactly one owner.
|
||||||
|
|
||||||
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||||
1. User presses pause → intent recorded → UI shows paused immediately
|
1. User presses pause → intent recorded → UI shows paused immediately
|
||||||
@@ -26,10 +27,13 @@ import time
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
|
BrowseMedia,
|
||||||
|
MediaClass,
|
||||||
MediaPlayerDeviceClass,
|
MediaPlayerDeviceClass,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
@@ -50,7 +54,6 @@ from .const import (
|
|||||||
CHANNEL_DIGIT_DELAY,
|
CHANNEL_DIGIT_DELAY,
|
||||||
CONF_ADB_COMMAND_ENABLED,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
|
||||||
CONF_APP_ICON_MAP,
|
CONF_APP_ICON_MAP,
|
||||||
CONF_APP_NAME_MAP,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_CHANNEL_DIGIT_DELAY,
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
@@ -63,7 +66,11 @@ from .const import (
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
PROTOCOL_MEDIA_KEYS,
|
PROTOCOL_MEDIA_KEYS,
|
||||||
PROTOCOL_REMOTE_KEYS,
|
PROTOCOL_REMOTE_KEYS,
|
||||||
|
SCRIPT_VAR_APP_NAME,
|
||||||
|
SCRIPT_VAR_LEVEL,
|
||||||
|
SCRIPT_VAR_MUTED,
|
||||||
TIVIMATE_PACKAGE,
|
TIVIMATE_PACKAGE,
|
||||||
|
command_scripts,
|
||||||
)
|
)
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
from .jellyfin import fetch_session_media_info
|
from .jellyfin import fetch_session_media_info
|
||||||
@@ -99,6 +106,44 @@ _PLAYBACK_FEATURES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_call_script(
|
||||||
|
hass: HomeAssistant, script: str, action: str, variables: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Call a configured script with variables, surfacing failures (t44/t47).
|
||||||
|
|
||||||
|
``blocking=True`` so a script that fails surfaces HERE: a command that cannot
|
||||||
|
run must be a visible, translated error rather than a silent no-op. Module-level
|
||||||
|
so the media player and the remote entity share one implementation.
|
||||||
|
"""
|
||||||
|
data: dict = {"entity_id": script}
|
||||||
|
if variables:
|
||||||
|
data["variables"] = variables
|
||||||
|
try:
|
||||||
|
await hass.services.async_call("script", "turn_on", data, blocking=True)
|
||||||
|
except Exception as err: # noqa: BLE001 - re-raised as a user-facing error
|
||||||
|
_LOGGER.error("the script for %s (%s) failed: %s", action, script, err)
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="command_script_failed",
|
||||||
|
translation_placeholders={
|
||||||
|
"action": action,
|
||||||
|
"script": script,
|
||||||
|
"error": str(err),
|
||||||
|
},
|
||||||
|
) from err
|
||||||
|
|
||||||
|
|
||||||
|
async def async_run_command_script(
|
||||||
|
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Run the script configured for ``command``; True when one handled it (t44)."""
|
||||||
|
script = command_scripts(options).get(command)
|
||||||
|
if not script:
|
||||||
|
return False
|
||||||
|
await async_call_script(hass, script, command, variables)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
@@ -118,8 +163,12 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
|
|||||||
legacy pattern no longer matches, so this never runs twice for an
|
legacy pattern no longer matches, so this never runs twice for an
|
||||||
entry. No behavior change: the entry's data/options are untouched.
|
entry. No behavior change: the entry's data/options are untouched.
|
||||||
"""
|
"""
|
||||||
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
# t41: the entity path is gone, so the SHAPE of the stale id is the marker.
|
||||||
if not legacy or entry.unique_id != legacy:
|
# Legacy entries (pre-t29 G2) carry the androidtv entity id as unique_id;
|
||||||
|
# every other shape (a MAC, the config-entry id, a yaml: seed id) is left
|
||||||
|
# exactly as it is.
|
||||||
|
legacy = entry.unique_id or ""
|
||||||
|
if not legacy.startswith("media_player."):
|
||||||
return
|
return
|
||||||
manager = getattr(entry, "runtime_data", None)
|
manager = getattr(entry, "runtime_data", None)
|
||||||
new_id = None
|
new_id = None
|
||||||
@@ -150,6 +199,61 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
|
|||||||
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
||||||
|
|
||||||
|
|
||||||
|
def entity_available(manager: ShieldConnectionManager | None) -> bool:
|
||||||
|
"""Is ANY usable transport healthy for this entry? (t40 live install)
|
||||||
|
|
||||||
|
The single ladder behind ``media_player.available``, ``remote.available``
|
||||||
|
and the diagnostics payload, so the three cannot disagree:
|
||||||
|
|
||||||
|
* the protocol transport is wired and has not reported the device
|
||||||
|
unreachable — it serves power/app/volume even when ADB is down;
|
||||||
|
* or ADB is connected (live state data);
|
||||||
|
* or fresh wake data exists (the poller has spoken at least once);
|
||||||
|
* or the manager is passive (no ADB configured at all): nothing to
|
||||||
|
transport is not a broken transport.
|
||||||
|
|
||||||
|
Unavailable only when a CONFIGURED transport is actively down and no usable
|
||||||
|
data exists. The t5-F2 rule ("an ADB manager that is down means unreliable
|
||||||
|
state") took the whole entity down while a healthy paired protocol transport
|
||||||
|
was still serving commands — the reported defect.
|
||||||
|
"""
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return True
|
||||||
|
if manager.protocol_active and manager.is_available is not False:
|
||||||
|
return True
|
||||||
|
if manager.connected:
|
||||||
|
return True
|
||||||
|
if manager.awake is not None:
|
||||||
|
return True
|
||||||
|
return not manager.available
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_app_name(
|
||||||
|
package: str | None,
|
||||||
|
app_name_map,
|
||||||
|
app_label,
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
|
||||||
|
|
||||||
|
The single chain behind ``media_player.app_name`` and the Current App
|
||||||
|
sensor:
|
||||||
|
|
||||||
|
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
||||||
|
2. the cached device-manifest label (``manager.app_label`` — a cache miss
|
||||||
|
enqueues the on-demand fetch and falls through);
|
||||||
|
3. the raw package text — a bare or unknown package falls back to itself,
|
||||||
|
never to None.
|
||||||
|
"""
|
||||||
|
if package:
|
||||||
|
mapped = (app_name_map or {}).get(package)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
label = app_label(package) if app_label is not None else None
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
return package or None
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||||
|
|
||||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||||
@@ -161,7 +265,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
||||||
self._unsub_listeners: list = []
|
self._unsub_listeners: list = []
|
||||||
|
|
||||||
@@ -192,15 +295,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""The bridge entities stay functional without ADB (passive manager).
|
"""Available while ANY usable transport is healthy (t40 live install).
|
||||||
|
|
||||||
Unavailable only while an active ADB manager is down — its data is
|
ADB enabled but unreachable no longer takes the entity down: a paired,
|
||||||
unreliable until the watchdog reconnects (t5-F2).
|
available protocol transport still serves power/app/volume. The ladder
|
||||||
|
is shared with the remote and diagnostics (``entity_available``).
|
||||||
"""
|
"""
|
||||||
manager = getattr(self._entry, "runtime_data", None)
|
return entity_available(getattr(self._entry, "runtime_data", None))
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
|
||||||
return True
|
|
||||||
return manager.connected or manager.awake is not None or not manager.available
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _jellyfin_entity(self) -> str | None:
|
def _jellyfin_entity(self) -> str | None:
|
||||||
@@ -272,11 +373,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._on_manager_update
|
self._on_manager_update
|
||||||
)
|
)
|
||||||
|
|
||||||
entities = [self._androidtv_entity]
|
entities = [
|
||||||
if self._jellyfin_entity:
|
entity_id
|
||||||
entities.append(self._jellyfin_entity)
|
for entity_id in (self._jellyfin_entity, self._power_entity)
|
||||||
if self._power_entity:
|
if entity_id
|
||||||
entities.append(self._power_entity)
|
]
|
||||||
self._unsub_listeners.append(
|
self._unsub_listeners.append(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass, entities, self._on_source_change
|
self.hass, entities, self._on_source_change
|
||||||
@@ -376,9 +477,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._manager.send_media_command(intent.adb_command)
|
self._manager.send_media_command(intent.adb_command)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._track_task(
|
# t41: neither of OUR transports served this command (both were
|
||||||
self._cmd(intent.command)
|
# down when it was sent), so there is nothing to re-send. Retrying
|
||||||
|
# by delegating to another integration is exactly what this change
|
||||||
|
# removes — surrender to the truth instead.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Intent %s had no serving transport to retry — falling back "
|
||||||
|
"to truth",
|
||||||
|
intent.command,
|
||||||
)
|
)
|
||||||
|
self._clear_intent()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
return
|
||||||
self._intent_timer_unsub = async_call_later(
|
self._intent_timer_unsub = async_call_later(
|
||||||
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||||
)
|
)
|
||||||
@@ -396,13 +506,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _on_source_change(self, event: Event) -> None:
|
def _on_source_change(self, event: Event) -> None:
|
||||||
"""React to state changes in androidtv or Jellyfin."""
|
"""React to state changes in Jellyfin or the power switch."""
|
||||||
entity_id = event.data.get("entity_id")
|
entity_id = event.data.get("entity_id")
|
||||||
new_state_obj = event.data.get("new_state")
|
new_state_obj = event.data.get("new_state")
|
||||||
|
|
||||||
# Power off clears any intent — device is off, intent is moot
|
# Power off clears any intent — device is off, intent is moot
|
||||||
if (
|
if (
|
||||||
entity_id in (self._androidtv_entity, self._power_entity)
|
entity_id == self._power_entity
|
||||||
and new_state_obj
|
and new_state_obj
|
||||||
and not self._is_power_on()
|
and not self._is_power_on()
|
||||||
and self._intent
|
and self._intent
|
||||||
@@ -565,12 +675,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
@property
|
@property
|
||||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
if self._jf_active():
|
if self._jf_active():
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
|
else:
|
||||||
# ADB media session also supports playback controls
|
# ADB media session also supports playback controls
|
||||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||||
if adb_info and (adb_info.playing or adb_info.paused):
|
if adb_info and (adb_info.playing or adb_info.paused):
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
return _BASE_FEATURES
|
else:
|
||||||
|
features = _BASE_FEATURES
|
||||||
|
|
||||||
|
# t44/t47: a command that exists ONLY as a script still has to be
|
||||||
|
# reachable, and the feature flag is what makes HA offer it. volume_set has
|
||||||
|
# no native path at all; select_source appears when EITHER a script is
|
||||||
|
# configured OR there is a source list to offer (t47 gave it a native path).
|
||||||
|
if self._script_for("volume_set"):
|
||||||
|
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||||
|
if self._script_for("select_source") or self.source_list:
|
||||||
|
features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
return features
|
||||||
|
|
||||||
# ── Metadata from Jellyfin ──
|
# ── Metadata from Jellyfin ──
|
||||||
|
|
||||||
@@ -580,22 +702,96 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def app_id(self):
|
def app_id(self):
|
||||||
"""Android TV entity first; standalone setups fall back to the
|
"""The raw foreground package from the manager's store (t41).
|
||||||
protocol current-app push (raw package — both stamp the store)."""
|
|
||||||
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
Both native transports stamp it: the protocol current-app push and the
|
||||||
if atv is not None:
|
ADB mResumedActivity parse. None until one of them has reported.
|
||||||
app_id = atv.attributes.get("app_id")
|
"""
|
||||||
if app_id:
|
return self._manager.current_app if self._manager is not None else None
|
||||||
return app_id
|
|
||||||
if self._manager is not None:
|
|
||||||
return self._manager.current_app
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _app_name_map(self) -> dict:
|
def _app_name_map(self) -> dict:
|
||||||
"""The user's apps name/icon map (P4-1 options editor)."""
|
"""The user's apps name/icon map (P4-1 options editor)."""
|
||||||
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
|
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _app_directory(self) -> dict:
|
||||||
|
"""Launchable apps: the user's map, else the curated default (P4-1).
|
||||||
|
|
||||||
|
ONE source for the browse tree, the source list and the reverse lookup, so
|
||||||
|
the dropdown can never offer a name the launch path cannot resolve.
|
||||||
|
|
||||||
|
The map is hand-editable, so it is NORMALIZED here — once, so that every
|
||||||
|
consumer sees the same thing (t51):
|
||||||
|
|
||||||
|
* a name is stripped, so display and match key are one string: an entry
|
||||||
|
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
|
||||||
|
"TiviMate", instead of being shown padded and missing every lookup;
|
||||||
|
* an entry whose name — or whose package — is blank/whitespace-only is
|
||||||
|
dropped, so no surface can offer a child with no package behind it or a
|
||||||
|
name no launch can reach (the t48 invariant, extended to the tree);
|
||||||
|
* two packages normalizing to the same name are BOTH KEPT — the browse
|
||||||
|
tree reaches each by its own package id, so neither app is hidden — while
|
||||||
|
the FIRST in map order wins the name: that is the single entry
|
||||||
|
source_list offers and the package select_source resolves. Map order is
|
||||||
|
the user's order, so the winner does not move between rebuilds. "The same
|
||||||
|
name" is judged case-insensitively (t53), because the reverse lookup is:
|
||||||
|
names differing only by case would otherwise give the dropdown a second
|
||||||
|
row that launches the FIRST entry's app.
|
||||||
|
|
||||||
|
A well-formed map (what the editor and the curated default produce) passes
|
||||||
|
through byte-identical: stripping only touches the edges, so a name like
|
||||||
|
"My App" keeps its inner space.
|
||||||
|
"""
|
||||||
|
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||||
|
normalized: dict[str, str] = {}
|
||||||
|
for package, name in directory.items():
|
||||||
|
key = str(package).strip()
|
||||||
|
label = str(name).strip()
|
||||||
|
if not key or not label:
|
||||||
|
continue
|
||||||
|
normalized[key] = label
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_list(self) -> list[str]:
|
||||||
|
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
|
||||||
|
|
||||||
|
Directory order (the user's map order, or the curated list), so rebuilding
|
||||||
|
the page does not shuffle it. Blanks are already gone and names already
|
||||||
|
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
|
||||||
|
one accessor so the dropdown, the browse tree and the reverse lookup cannot
|
||||||
|
disagree.
|
||||||
|
|
||||||
|
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
|
||||||
|
first spelling the user typed. Exact duplicates were already collapsed, but
|
||||||
|
``Dup``/``dup`` were not: the dropdown then showed two rows while the
|
||||||
|
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
|
||||||
|
of them to the first entry, so the second row launched the wrong app. One
|
||||||
|
row per name means a row can only launch the app it shows. The shadowed
|
||||||
|
package is not lost: the browse tree still lists it under its own id, and
|
||||||
|
select_source accepts that id.
|
||||||
|
"""
|
||||||
|
seen: set[str] = set()
|
||||||
|
offered: list[str] = []
|
||||||
|
for name in self._app_directory.values():
|
||||||
|
folded = name.casefold()
|
||||||
|
if folded in seen:
|
||||||
|
continue
|
||||||
|
seen.add(folded)
|
||||||
|
offered.append(name)
|
||||||
|
return offered
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str | None:
|
||||||
|
"""The foreground app's friendly name — the same chain as app_name (t47).
|
||||||
|
|
||||||
|
``resolve_app_name``: the user's map, then the cached device-manifest label,
|
||||||
|
then the raw package. Same answer as the Current App sensor, so a
|
||||||
|
universal-media-player replacement cannot see two different truths.
|
||||||
|
"""
|
||||||
|
return self.app_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_name(self):
|
def app_name(self):
|
||||||
"""P5-1 resolution order — explicit override first:
|
"""P5-1 resolution order — explicit override first:
|
||||||
@@ -603,25 +799,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
1. CONF_APP_NAME_MAP (the user's explicit override)
|
1. CONF_APP_NAME_MAP (the user's explicit override)
|
||||||
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
||||||
demand — a miss enqueues the fetch and falls through)
|
demand — a miss enqueues the fetch and falls through)
|
||||||
3. androidtv entity tier (when configured)
|
3. raw package (protocol push / ADB foreground parse)
|
||||||
4. raw package (protocol push / ADB foreground parse)
|
|
||||||
"""
|
"""
|
||||||
package = self._manager.foreground_app if self._manager else None
|
# t40: one implementation, shared with the Current App sensor
|
||||||
if package:
|
# (``resolve_app_name``) — the two can no longer disagree about what
|
||||||
mapped = self._app_name_map.get(package)
|
# is on screen.
|
||||||
if mapped:
|
package = self._manager.foreground_app if self._manager is not None else None
|
||||||
return mapped
|
return resolve_app_name(
|
||||||
label = self._manager.app_label(package)
|
package,
|
||||||
if label:
|
self._app_name_map,
|
||||||
return label
|
self._manager.app_label if self._manager is not None else None,
|
||||||
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
)
|
||||||
if atv is not None:
|
|
||||||
app_name = atv.attributes.get("app_name")
|
|
||||||
if app_name:
|
|
||||||
return app_name
|
|
||||||
if self._manager is not None and package:
|
|
||||||
return package
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_content_type(self):
|
def media_content_type(self):
|
||||||
@@ -822,7 +1010,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""Extra state attributes — a PROPERTY, as HA requires (t40 blocker).
|
||||||
|
|
||||||
|
``Entity.__async_calculate_state`` merges them with
|
||||||
|
``attr |= self.extra_state_attributes``; declaring this as a plain
|
||||||
|
method made every state write raise
|
||||||
|
``TypeError: 'method' object is not iterable``, so the entity could
|
||||||
|
never render on a live install.
|
||||||
|
"""
|
||||||
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
||||||
# then the foreground tier (P3-3a).
|
# then the foreground tier (P3-3a).
|
||||||
now_playing = self._get_now_playing()
|
now_playing = self._get_now_playing()
|
||||||
@@ -858,30 +1055,119 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
attrs["_intent"] = self._intent.to_debug_dict()
|
attrs["_intent"] = self._intent.to_debug_dict()
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
# ── Commands — ALL go to androidtv_remote ──
|
# ── Commands — our own transports only (t41: the external entity is gone) ──
|
||||||
|
|
||||||
async def _cmd(self, service: str, **extra) -> None:
|
async def _power_key(self, *, send_when_on: bool) -> None:
|
||||||
data = {"entity_id": self._androidtv_entity, **extra}
|
"""Send the protocol POWER key when the known state disagrees (t41).
|
||||||
await self.hass.services.async_call("media_player", service, data)
|
|
||||||
|
Both native transports are ours now, so this no longer delegates to an
|
||||||
|
androidtv_remote entity: the protocol POWER key is the path we own. An
|
||||||
|
unknown state sends nothing (no superfluous toggle — the same rule the
|
||||||
|
remote entity applies), and without the protocol transport there is
|
||||||
|
nothing to transport the key over.
|
||||||
|
"""
|
||||||
|
manager = self._manager
|
||||||
|
if manager is None or manager.is_on is not send_when_on:
|
||||||
|
return
|
||||||
|
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
|
||||||
|
|
||||||
|
def _script_for(self, command: str) -> str | None:
|
||||||
|
"""The script configured to own ``command``, if any (t44)."""
|
||||||
|
return command_scripts(self._entry.options).get(command)
|
||||||
|
|
||||||
|
async def _script_served(self, command: str, variables: dict | None = None) -> bool:
|
||||||
|
"""True when a configured script handled ``command`` instead of the transports."""
|
||||||
|
return await async_run_command_script(
|
||||||
|
self.hass, self._entry.options, command, variables
|
||||||
|
)
|
||||||
|
|
||||||
async def async_turn_on(self):
|
async def async_turn_on(self):
|
||||||
await self._cmd("turn_on")
|
"""Wake the Shield (only when it reports OFF) — or run the turn_on script."""
|
||||||
|
if await self._script_served("turn_on"):
|
||||||
|
return
|
||||||
|
await self._power_key(send_when_on=False)
|
||||||
|
|
||||||
async def async_turn_off(self):
|
async def async_turn_off(self):
|
||||||
await self._cmd("turn_off")
|
"""Sleep the Shield (only when it reports ON) — or run the turn_off script."""
|
||||||
|
if await self._script_served("turn_off"):
|
||||||
|
return
|
||||||
|
await self._power_key(send_when_on=True)
|
||||||
|
|
||||||
async def async_volume_up(self):
|
async def async_volume_up(self):
|
||||||
|
if await self._script_served("volume_up"):
|
||||||
|
return
|
||||||
await self._remote_key_cmd("volume_up", "volume_up")
|
await self._remote_key_cmd("volume_up", "volume_up")
|
||||||
|
|
||||||
async def async_volume_down(self):
|
async def async_volume_down(self):
|
||||||
|
if await self._script_served("volume_down"):
|
||||||
|
return
|
||||||
await self._remote_key_cmd("volume_down", "volume_down")
|
await self._remote_key_cmd("volume_down", "volume_down")
|
||||||
|
|
||||||
async def async_mute_volume(self, mute):
|
async def async_mute_volume(self, mute):
|
||||||
"""Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL)."""
|
"""Mute — via the configured script, else the protocol/ADB key (C-VOL)."""
|
||||||
|
if await self._script_served("volume_mute", {SCRIPT_VAR_MUTED: mute}):
|
||||||
|
return
|
||||||
await self._remote_key_cmd("volume_mute", "mute")
|
await self._remote_key_cmd("volume_mute", "mute")
|
||||||
|
|
||||||
|
async def async_set_volume_level(self, volume):
|
||||||
|
"""Set absolute volume — reachable only with a script (t44).
|
||||||
|
|
||||||
|
There is no native path to replace: the protocol carries UP/DOWN/MUTE keys
|
||||||
|
only (C-VOL) and ADB has no absolute volume either. Configured, this is the
|
||||||
|
hook for a device whose volume is owned elsewhere (a speaker switch);
|
||||||
|
unconfigured it stays the no-op it has always been.
|
||||||
|
"""
|
||||||
|
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
|
||||||
|
|
||||||
|
def _source_package(self, source) -> str | None:
|
||||||
|
"""The package behind a source name, or a package id passed through (t47).
|
||||||
|
|
||||||
|
Both sides are normalized the same way: the query is stripped here and the
|
||||||
|
names were stripped when the directory was built (t51), so a padded entry
|
||||||
|
matches its trimmed spelling. Names are compared case-insensitively — which
|
||||||
|
is why ``source_list`` collapses names that differ only by case (t53), so
|
||||||
|
that the row it offers resolves back to the same app it displays. When two
|
||||||
|
packages share a name the first in map order wins, the same one
|
||||||
|
``source_list`` offers (see ``_app_directory``); the shadowed package stays
|
||||||
|
reachable by passing its id.
|
||||||
|
"""
|
||||||
|
directory = self._app_directory
|
||||||
|
wanted = str(source or "").strip()
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
if wanted in directory:
|
||||||
|
# an automation may hand us the package directly; that is what the
|
||||||
|
# browse tree publishes as media_content_id
|
||||||
|
return wanted
|
||||||
|
folded = wanted.casefold()
|
||||||
|
for package, name in directory.items():
|
||||||
|
if str(name).casefold() == folded:
|
||||||
|
return package
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_select_source(self, source):
|
||||||
|
"""Select an app by its friendly name (t47) — script first, else launch it.
|
||||||
|
|
||||||
|
A configured ``select_source`` script keeps precedence (t44: selecting a
|
||||||
|
source can be more than launching an app — an AVR input, a speaker switch).
|
||||||
|
Without one the name is resolved back to its package through the SAME
|
||||||
|
directory the source list came from and launched over the shared
|
||||||
|
``play_media("app", …)`` path, so a universal-media-player replacement gets
|
||||||
|
the native behaviour. An unknown name is an error, never a silent no-op.
|
||||||
|
"""
|
||||||
|
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
|
||||||
|
return
|
||||||
|
package = self._source_package(source)
|
||||||
|
if package is None:
|
||||||
|
raise ServiceValidationError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="unknown_source",
|
||||||
|
translation_placeholders={"source": str(source)},
|
||||||
|
)
|
||||||
|
await self.async_play_media(MediaType.APP, package)
|
||||||
|
|
||||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||||
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
|
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||||
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
||||||
if (
|
if (
|
||||||
protocol_key
|
protocol_key
|
||||||
@@ -891,16 +1177,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return
|
return
|
||||||
if self._manager is not None and await self._manager.send_media_command(adb_key):
|
if self._manager is not None and await self._manager.send_media_command(adb_key):
|
||||||
return
|
return
|
||||||
# Transitional: external androidtv_remote entity (removed in Phase 4)
|
|
||||||
await self._cmd(remote_key)
|
|
||||||
|
|
||||||
async def _media_cmd(
|
async def _media_cmd(
|
||||||
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
|
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a media command over the fastest available command path.
|
"""Send a media command over the fastest available command path.
|
||||||
|
|
||||||
Order (Phase 2): protocol key push → ADB keyevent → the external
|
Order: protocol key push → ADB keyevent (t41: our own transports only —
|
||||||
androidtv_remote entity (transitional until Phase 4 removes it).
|
the transitional external-entity tier is gone). With neither transport
|
||||||
|
up the command is a no-op, never a call into another integration.
|
||||||
"""
|
"""
|
||||||
if intent_state:
|
if intent_state:
|
||||||
self._set_intent(ha_service, intent_state)
|
self._set_intent(ha_service, intent_state)
|
||||||
@@ -933,9 +1218,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
self._intent.adb_command = adb_command
|
self._intent.adb_command = adb_command
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
|
|
||||||
await self._cmd(ha_service)
|
|
||||||
|
|
||||||
async def async_media_play(self):
|
async def async_media_play(self):
|
||||||
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
||||||
|
|
||||||
@@ -961,36 +1243,40 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
# ── BrowseMedia app directory (P4-2c) ──
|
# ── BrowseMedia app directory (P4-2c) ──
|
||||||
|
|
||||||
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||||
"""Root lists the apps map (or the curated default) → play_media(app)."""
|
"""Root lists the apps map (or the curated default) → play_media(app).
|
||||||
from homeassistant.components.media_player import (
|
|
||||||
BrowseMediaSource,
|
|
||||||
MediaClass,
|
|
||||||
)
|
|
||||||
|
|
||||||
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
t37 D1: the standard media_player tree, exactly as core's own
|
||||||
# G18 (2026-05-20 blog): BrowseMediaSource must carry the domain
|
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
|
||||||
# parameter so the frontend can attribute the media tree.
|
homeassistant.components.media_player, every node carrying an explicit
|
||||||
|
media_content_id/media_content_type, and the directory naming the
|
||||||
|
class of its children. ``BrowseMediaSource`` only ever existed in
|
||||||
|
homeassistant.components.media_source.models (t30 G18's domain
|
||||||
|
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
|
||||||
|
so an app-directory node must never claim one — and the import above
|
||||||
|
removed a name media_player has not exported for a long time, which
|
||||||
|
made every real browse raise.
|
||||||
|
"""
|
||||||
|
app_map = self._app_directory
|
||||||
children = [
|
children = [
|
||||||
BrowseMediaSource(
|
BrowseMedia(
|
||||||
title=name,
|
title=name,
|
||||||
media_class=MediaClass.APP,
|
media_class=MediaClass.APP,
|
||||||
media_content_type="app",
|
media_content_type=MediaType.APP,
|
||||||
media_content_id=pkg,
|
media_content_id=pkg,
|
||||||
can_play=True,
|
can_play=True,
|
||||||
can_expand=False,
|
can_expand=False,
|
||||||
domain=DOMAIN,
|
|
||||||
)
|
)
|
||||||
for pkg, name in app_map.items()
|
for pkg, name in app_map.items()
|
||||||
]
|
]
|
||||||
return BrowseMediaSource(
|
return BrowseMedia(
|
||||||
title="Apps",
|
title="Apps",
|
||||||
media_class=MediaClass.DIRECTORY,
|
media_class=MediaClass.DIRECTORY,
|
||||||
media_content_type="apps",
|
media_content_type=MediaType.APPS,
|
||||||
media_content_id="apps",
|
media_content_id="apps",
|
||||||
|
children_media_class=MediaClass.APP,
|
||||||
can_play=False,
|
can_play=False,
|
||||||
can_expand=True,
|
can_expand=True,
|
||||||
children=children,
|
children=children,
|
||||||
domain=DOMAIN,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── play_media (§7): channel digits, app/url launch ──
|
# ── play_media (§7): channel digits, app/url launch ──
|
||||||
@@ -1066,16 +1352,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
|
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
|
||||||
try:
|
try:
|
||||||
for digit in digits:
|
for digit in digits:
|
||||||
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
|
# 1. Protocol digit key; 2. ADB keyevent 7..16 (t41: our own
|
||||||
|
# transports only — an unserved digit is dropped, never handed
|
||||||
|
# to another integration)
|
||||||
served = False
|
served = False
|
||||||
if self._manager is not None:
|
if self._manager is not None:
|
||||||
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
|
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
|
||||||
if not served:
|
if not served:
|
||||||
served = await self._manager.send_digit_key(digit)
|
served = await self._manager.send_digit_key(digit)
|
||||||
if not served:
|
if not served:
|
||||||
await self._cmd(
|
_LOGGER.warning(
|
||||||
"play_media",
|
"channel digit %s not sent — no available transport", digit
|
||||||
media={"media_content_id": digit, "media_content_type": "channel"},
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
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:"
|
_START_PREFIX = "START_LONG:"
|
||||||
_END_PREFIX = "END_LONG:"
|
_END_PREFIX = "END_LONG:"
|
||||||
@@ -93,17 +102,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
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:
|
The same ladder as the media player (``entity_available``): protocol
|
||||||
nothing to transport is not a broken one.
|
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()
|
return entity_available(self._manager_of())
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
|
||||||
return True
|
|
||||||
if not manager.protocol_active:
|
|
||||||
return True
|
|
||||||
return manager.is_available is not False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def activity(self) -> str | None:
|
def activity(self) -> str | None:
|
||||||
@@ -133,7 +139,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
self._manager_unregistered = None
|
self._manager_unregistered = None
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs) -> 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()
|
manager = self._manager_of()
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
return
|
return
|
||||||
@@ -143,7 +153,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs) -> None:
|
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()
|
manager = self._manager_of()
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
return
|
return
|
||||||
@@ -158,7 +170,29 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
|||||||
delay_secs: float = 0.0,
|
delay_secs: float = 0.0,
|
||||||
hold_secs: float = 0.0,
|
hold_secs: float = 0.0,
|
||||||
) -> None:
|
) -> 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()
|
manager = self._manager_of()
|
||||||
if not isinstance(manager, ShieldConnectionManager):
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ from homeassistant.helpers.event import async_track_state_change_event
|
|||||||
|
|
||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_APP_NAME_MAP,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .media_player import resolve_app_name
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -65,9 +66,9 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||||
"""Current running app on the Shield.
|
"""Current running app on the Shield.
|
||||||
|
|
||||||
Primary source: the androidtv entity's own state. When that entity is
|
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
||||||
unavailable, falls back to the ShieldConnectionManager's StateStore
|
push, or the most recent visible ADB recent-task app when that has not
|
||||||
(most recent visible ADB recent-task app).
|
reported (t41: our own transports only).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_attr_translation_key = "current_app"
|
_attr_translation_key = "current_app"
|
||||||
@@ -75,7 +76,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
super().__init__(hass, entry)
|
super().__init__(hass, entry)
|
||||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
def _manager(self) -> ShieldConnectionManager | None:
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
@@ -83,34 +83,24 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> 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()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is not None:
|
||||||
# Refresh when the connection manager updates its store.
|
# Refresh when the connection manager updates its store.
|
||||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
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
|
@callback
|
||||||
def _handle_store_update(self) -> None:
|
def _handle_store_update(self) -> None:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@property
|
def _package(self) -> str | None:
|
||||||
def native_value(self) -> str | None:
|
"""The raw foreground package from the manager's store (t41).
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
|
||||||
if state:
|
|
||||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
|
||||||
|
|
||||||
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
||||||
# protocol current_app push → ADB mResumedActivity → ADB recents
|
mResumedActivity → ADB recents.
|
||||||
|
"""
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is None:
|
||||||
|
return None
|
||||||
foreground = manager.foreground_app
|
foreground = manager.foreground_app
|
||||||
if foreground:
|
if foreground:
|
||||||
return foreground
|
return foreground
|
||||||
@@ -120,19 +110,32 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def native_value(self) -> str | None:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
"""The resolved friendly name (t40), not the raw package id.
|
||||||
if state:
|
|
||||||
return {
|
Exactly the ``media_player.app_name`` chain via the shared
|
||||||
"app_id": state.attributes.get("app_id"),
|
``resolve_app_name``: apps map → cached manifest label → the package
|
||||||
"app_name": state.attributes.get("app_name"),
|
text itself. A bare or unknown package shows its package text rather
|
||||||
}
|
than None, like the media player's app_name.
|
||||||
|
"""
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
if manager is not None:
|
return resolve_app_name(
|
||||||
running = manager.running_apps
|
self._package(),
|
||||||
if running:
|
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||||
return {"app_id": running[0].get("package")}
|
manager.app_label if manager is not None else None,
|
||||||
return {}
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""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 NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
|
|||||||
@@ -3,65 +3,82 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "NVIDIA Shield",
|
"title": "NVIDIA Shield",
|
||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Name",
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"host": "The Shield's IP address",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
"protocol_host": "Protocol Host"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled 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_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||||
"adb_port": "ADB port number. Default is 5555.",
|
"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_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
|
||||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
"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.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"skip_pairing": "Continue without ADB pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||||
|
"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": {
|
"protocol_pair": {
|
||||||
"title": "Pair Remote Protocol",
|
"title": "Pair the remote protocol",
|
||||||
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
"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'.",
|
||||||
"data": {
|
"data": {
|
||||||
"pairing_code": "Pairing code shown on the Shield"
|
"pairing_code": "Pairing code shown on the Shield",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the 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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"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": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"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": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click 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. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||||
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield is already set up.",
|
||||||
"reauth_successful": "Re-paired successfully.",
|
"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.",
|
"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_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
"yaml_seed_only": {},
|
"yaml_seed_only": {},
|
||||||
"yaml_seed": {
|
"yaml_seed": {
|
||||||
@@ -73,37 +90,48 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield Settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Configure linked devices and monitoring.",
|
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
"adb_host": "ADB Host",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_port": "ADB Port",
|
"host": "The Shield's IP address",
|
||||||
"app_name_map": "Apps name/icon map",
|
"adb_port": "ADB port",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"app_name_map": "App names and icons",
|
||||||
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
"logcat_events": "Live events push (logcat)"
|
"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": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
"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 this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
"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": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||||
"adb_port": "ADB port (default 5555).",
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
"adb_port": "The ADB port. The standard value is 5555.",
|
||||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
"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.",
|
||||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
"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_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
"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).",
|
||||||
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +164,14 @@
|
|||||||
"adb_unavailable": {
|
"adb_unavailable": {
|
||||||
"title": "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 \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"unknown_source": {
|
||||||
|
"title": "Unknown source",
|
||||||
|
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||||
|
},
|
||||||
|
"command_script_failed": {
|
||||||
|
"title": "Command script failed",
|
||||||
|
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
|||||||
@@ -3,65 +3,82 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "NVIDIA Shield",
|
"title": "NVIDIA Shield",
|
||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Name",
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_host": "ADB Host",
|
"host": "The Shield's IP address",
|
||||||
"adb_port": "ADB Port",
|
"adb_port": "ADB port",
|
||||||
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
"protocol_host": "Protocol Host"
|
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled 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_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||||
"adb_port": "ADB port number. Default is 5555.",
|
"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_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
|
||||||
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "Allow ADB access",
|
||||||
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
|
"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.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"skip_pairing": "Continue without ADB pairing"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||||
|
"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": {
|
"protocol_pair": {
|
||||||
"title": "Pair Remote Protocol",
|
"title": "Pair the remote protocol",
|
||||||
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
"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'.",
|
||||||
"data": {
|
"data": {
|
||||||
"pairing_code": "Pairing code shown on the Shield"
|
"pairing_code": "Pairing code shown on the Shield",
|
||||||
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"title": "Re-pair Nvidia Shield",
|
"title": "Re-pair Nvidia Shield",
|
||||||
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the 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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"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": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
"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": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||||
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||||
"protocol_pair_failed": "The Shield rejected that pairing code. Click 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. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||||
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured.",
|
"already_configured": "This Shield is already set up.",
|
||||||
"reauth_successful": "Re-paired successfully.",
|
"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.",
|
"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_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address.",
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
"yaml_seed_only": {},
|
"yaml_seed_only": {},
|
||||||
"yaml_seed": {
|
"yaml_seed": {
|
||||||
@@ -73,37 +90,48 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NVIDIA Shield Settings",
|
"title": "NVIDIA Shield settings",
|
||||||
"description": "Configure linked devices and monitoring.",
|
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
|
||||||
"data": {
|
"data": {
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver input",
|
||||||
"power_entity": "Power Switch",
|
"power_entity": "Power switch",
|
||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Read state over ADB",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
"adb_host": "ADB Host",
|
"adb_store_app_info": "Show title and artist",
|
||||||
"adb_port": "ADB Port",
|
"host": "The Shield's IP address",
|
||||||
"app_name_map": "Apps name/icon map",
|
"adb_port": "ADB port",
|
||||||
"adb_command_enabled": "Enable raw ADB shell service",
|
"app_name_map": "App names and icons",
|
||||||
|
"adb_command_enabled": "Enable the adb_command service",
|
||||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
"channel_send_enter": "Send ENTER after channel digits",
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
"logcat_events": "Live events push (logcat)"
|
"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": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
"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 this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
"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": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||||
"adb_port": "ADB port (default 5555).",
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||||
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
"adb_port": "The ADB port. The standard value is 5555.",
|
||||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
"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.",
|
||||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
"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_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
"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).",
|
||||||
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
"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.",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +164,14 @@
|
|||||||
"adb_unavailable": {
|
"adb_unavailable": {
|
||||||
"title": "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 \u2014 command not sent."
|
||||||
|
},
|
||||||
|
"unknown_source": {
|
||||||
|
"title": "Unknown source",
|
||||||
|
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||||
|
},
|
||||||
|
"command_script_failed": {
|
||||||
|
"title": "Command script failed",
|
||||||
|
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
|||||||
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.
|
# Stdlib-only integration modules.
|
||||||
for _mod in ("const", "adb_session", "connection"):
|
for _mod in ("const", "adb_session", "connection"):
|
||||||
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
||||||
|
|
||||||
+245
-22
@@ -10,6 +10,9 @@ import asyncio
|
|||||||
import enum
|
import enum
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
import voluptuous_mirror
|
||||||
|
|
||||||
ENTITIES_STUB_NAMES = (
|
ENTITIES_STUB_NAMES = (
|
||||||
"voluptuous",
|
"voluptuous",
|
||||||
@@ -27,6 +30,7 @@ ENTITIES_STUB_NAMES = (
|
|||||||
"homeassistant.helpers.device_registry",
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"homeassistant.helpers.event",
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.redact",
|
||||||
"homeassistant.helpers.entity",
|
"homeassistant.helpers.entity",
|
||||||
"homeassistant.helpers.entity_platform",
|
"homeassistant.helpers.entity_platform",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
@@ -38,6 +42,37 @@ 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
|
||||||
|
selector_module.EntitySelectorConfig = lambda *args, **kwargs: ""
|
||||||
|
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||||
|
selector_module.SelectSelectorConfig = lambda **kwargs: ""
|
||||||
|
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerState(enum.StrEnum):
|
class MediaPlayerState(enum.StrEnum):
|
||||||
OFF = "off"
|
OFF = "off"
|
||||||
ON = "on"
|
ON = "on"
|
||||||
@@ -59,38 +94,98 @@ class MediaPlayerEntityFeature(enum.IntFlag):
|
|||||||
PLAY_MEDIA = 256
|
PLAY_MEDIA = 256
|
||||||
NEXT_TRACK = 512
|
NEXT_TRACK = 512
|
||||||
PREVIOUS_TRACK = 1024
|
PREVIOUS_TRACK = 1024
|
||||||
|
SELECT_SOURCE = 2048
|
||||||
|
|
||||||
|
|
||||||
class MediaClass:
|
class MediaClass:
|
||||||
|
"""homeassistant.components.media_player.MediaClass (values we use)."""
|
||||||
|
|
||||||
APP = "app"
|
APP = "app"
|
||||||
DIRECTORY = "directory"
|
DIRECTORY = "directory"
|
||||||
|
|
||||||
|
|
||||||
class BrowseMediaSource:
|
class MediaType:
|
||||||
"""HA BrowseMedia shape used by the app directory tests."""
|
"""homeassistant.components.media_player.MediaType (values we use)."""
|
||||||
|
|
||||||
|
APP = "app"
|
||||||
|
APPS = "apps"
|
||||||
|
|
||||||
|
|
||||||
|
class BrowseMedia:
|
||||||
|
"""The REAL media_player.BrowseMedia shape (t37 D1).
|
||||||
|
|
||||||
|
Keyword-only with the six arguments real HA requires, the optional ones it
|
||||||
|
accepts, a faithful ``as_dict`` and — crucially — NO ``domain`` parameter:
|
||||||
|
``BrowseMediaSource`` lives in
|
||||||
|
``homeassistant.components.media_source.models`` (its contract is the
|
||||||
|
``media_source://<domain>`` id) and is NOT importable from
|
||||||
|
``homeassistant.components.media_player`` at all. The old import and the
|
||||||
|
old ``domain=`` kwarg therefore fail IN-SUITE instead of hiding behind a
|
||||||
|
permissive stub.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
title,
|
|
||||||
media_class,
|
media_class,
|
||||||
media_content_type,
|
|
||||||
media_content_id,
|
media_content_id,
|
||||||
|
media_content_type,
|
||||||
|
title,
|
||||||
can_play,
|
can_play,
|
||||||
can_expand,
|
can_expand,
|
||||||
domain=None, # t30 G18 (2026-05-20 blog contract)
|
|
||||||
children=None,
|
children=None,
|
||||||
|
children_media_class=None,
|
||||||
thumbnail=None,
|
thumbnail=None,
|
||||||
|
not_shown=0,
|
||||||
|
can_search=False,
|
||||||
|
search_media_classes=None,
|
||||||
):
|
):
|
||||||
self.title = title
|
|
||||||
self.media_class = media_class
|
self.media_class = media_class
|
||||||
self.media_content_type = media_content_type
|
|
||||||
self.media_content_id = media_content_id
|
self.media_content_id = media_content_id
|
||||||
|
self.media_content_type = media_content_type
|
||||||
|
self.title = title
|
||||||
self.can_play = can_play
|
self.can_play = can_play
|
||||||
self.can_expand = can_expand
|
self.can_expand = can_expand
|
||||||
self.domain = domain
|
self.children = children
|
||||||
self.children = children or []
|
self.children_media_class = children_media_class
|
||||||
self.thumbnail = thumbnail
|
self.thumbnail = thumbnail
|
||||||
|
self.not_shown = not_shown
|
||||||
|
self.can_search = can_search
|
||||||
|
self.search_media_classes = search_media_classes
|
||||||
|
|
||||||
|
def as_dict(self, *, parent=True):
|
||||||
|
"""Real payload conversion — what the frontend actually consumes."""
|
||||||
|
if self.children_media_class is None and self.children:
|
||||||
|
self.calculate_children_class()
|
||||||
|
response = {
|
||||||
|
"title": self.title,
|
||||||
|
"media_class": self.media_class,
|
||||||
|
"media_content_type": self.media_content_type,
|
||||||
|
"media_content_id": self.media_content_id,
|
||||||
|
"children_media_class": self.children_media_class,
|
||||||
|
"can_play": self.can_play,
|
||||||
|
"can_expand": self.can_expand,
|
||||||
|
"can_search": self.can_search,
|
||||||
|
"search_media_classes": self.search_media_classes,
|
||||||
|
"thumbnail": self.thumbnail,
|
||||||
|
}
|
||||||
|
if not parent:
|
||||||
|
return response
|
||||||
|
response["not_shown"] = self.not_shown
|
||||||
|
response["children"] = (
|
||||||
|
[child.as_dict(parent=False) for child in self.children]
|
||||||
|
if self.children
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def calculate_children_class(self):
|
||||||
|
"""Real helper: the children's common class, else DIRECTORY."""
|
||||||
|
self.children_media_class = MediaClass.DIRECTORY
|
||||||
|
assert self.children is not None
|
||||||
|
proposed_class = self.children[0].media_class
|
||||||
|
if all(child.media_class == proposed_class for child in self.children):
|
||||||
|
self.children_media_class = proposed_class
|
||||||
|
|
||||||
|
|
||||||
class MediaEntityManager:
|
class MediaEntityManager:
|
||||||
@@ -109,15 +204,36 @@ class RemoteEntity:
|
|||||||
return None
|
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:
|
def install() -> None:
|
||||||
"""Register the stub modules (idempotent)."""
|
"""Register the stub modules (idempotent, and self-healing).
|
||||||
if "homeassistant.components.media_player" in sys.modules:
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
vol = types.ModuleType("voluptuous")
|
vol = types.ModuleType("voluptuous")
|
||||||
vol.Required = lambda key, default=None: key
|
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
|
||||||
vol.Optional = lambda key, default=None: key
|
# stub cannot tell a schema WITH a default from one without.
|
||||||
vol.Schema = lambda schema: schema
|
voluptuous_mirror.install(vol)
|
||||||
sys.modules["voluptuous"] = vol
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
ha = types.ModuleType("homeassistant")
|
ha = types.ModuleType("homeassistant")
|
||||||
@@ -144,16 +260,24 @@ def install() -> None:
|
|||||||
async_set_unique_id = _stub_set_unique_id
|
async_set_unique_id = _stub_set_unique_id
|
||||||
_abort_if_unique_id_configured = _noop_abort
|
_abort_if_unique_id_configured = _noop_abort
|
||||||
|
|
||||||
def _async_handle_discovery_without_unique_id(self):
|
async def _async_handle_discovery_without_unique_id(self):
|
||||||
# t32 F2 (audited helper): recording no-op at hub scope
|
# t37 D3: the real helper is a COROUTINE and core awaits it — a
|
||||||
pass
|
# sync stub here would turn the production `await` into a
|
||||||
|
# TypeError. The abort semantics live in
|
||||||
|
# tests/test_standalone_setup.py, whose stub owns entry/in-progress
|
||||||
|
# state; at hub scope there is nothing to abort on.
|
||||||
|
return None
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
"type": "form",
|
"type": "form",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"errors": dict(errors or {}),
|
"errors": dict(errors or {}),
|
||||||
"data_schema": data_schema,
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
@@ -167,6 +291,11 @@ def install() -> None:
|
|||||||
return entry
|
return entry
|
||||||
return None # the caller's cached entry is used
|
return None # the caller's cached entry is used
|
||||||
|
|
||||||
|
def _get_reconfigure_entry(self, entry=None):
|
||||||
|
if entry is not None:
|
||||||
|
return entry
|
||||||
|
return None # the caller's cached entry is used
|
||||||
|
|
||||||
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
||||||
# which updates, schedules the reload and aborts; the stub records both.
|
# which updates, schedules the reload and aborts; the stub records both.
|
||||||
|
|
||||||
@@ -231,12 +360,19 @@ def install() -> None:
|
|||||||
|
|
||||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||||
|
|
||||||
class ServiceValidationErrorStubError(Exception):
|
class HomeAssistantErrorStubError(Exception):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args or (kwargs.get("translation_key"),))
|
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||||
self.translation_domain = kwargs.get("translation_domain")
|
self.translation_domain = kwargs.get("translation_domain")
|
||||||
self.translation_key = kwargs.get("translation_key")
|
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
|
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||||
sys.modules["homeassistant.exceptions"] = exceptions
|
sys.modules["homeassistant.exceptions"] = exceptions
|
||||||
|
|
||||||
@@ -267,9 +403,12 @@ def install() -> None:
|
|||||||
mp.MediaPlayerEntity = MediaPlayerEntity
|
mp.MediaPlayerEntity = MediaPlayerEntity
|
||||||
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
||||||
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
||||||
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor
|
mp.BrowseMedia = BrowseMedia
|
||||||
mp.BrowseMediaSource = BrowseMediaSource
|
|
||||||
mp.MediaClass = MediaClass
|
mp.MediaClass = MediaClass
|
||||||
|
mp.MediaType = MediaType
|
||||||
|
# NOTE (t37 D1): homeassistant.components.media_player exposes NO
|
||||||
|
# BrowseMediaSource — deliberately not stubbed, so the drifted import
|
||||||
|
# fails here exactly as it does in a real install.
|
||||||
mp.MediaPlayerState = MediaPlayerState
|
mp.MediaPlayerState = MediaPlayerState
|
||||||
sys.modules["homeassistant.components.media_player"] = mp
|
sys.modules["homeassistant.components.media_player"] = mp
|
||||||
|
|
||||||
@@ -321,7 +460,7 @@ def install() -> None:
|
|||||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||||
helpers.event = event
|
helpers.event = event
|
||||||
helpers.selector = types.SimpleNamespace(
|
helpers.selector = types.SimpleNamespace(
|
||||||
EntitySelector=lambda *a, **k: None,
|
EntitySelector=EntitySelectorStub,
|
||||||
EntitySelectorConfig=lambda *a, **k: "",
|
EntitySelectorConfig=lambda *a, **k: "",
|
||||||
SelectSelector=lambda *a, **k: None,
|
SelectSelector=lambda *a, **k: None,
|
||||||
SelectSelectorConfig=lambda **k: "",
|
SelectSelectorConfig=lambda **k: "",
|
||||||
@@ -336,12 +475,91 @@ def install() -> None:
|
|||||||
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||||
entity_platform.AddEntitiesCallback = object
|
entity_platform.AddEntitiesCallback = object
|
||||||
helpers.entity_platform = entity_platform
|
helpers.entity_platform = entity_platform
|
||||||
|
|
||||||
|
# t37 D2: homeassistant.helpers.redact.async_redact_data — the REAL helper
|
||||||
|
# (diagnostics.py imports it at module scope; `homeassistant.util.diagnostic`
|
||||||
|
# never existed). Modelled faithfully: the same masking of keys found in
|
||||||
|
# ``to_redact``, the same nested mapping/list recursion, the same skip of
|
||||||
|
# None/empty-string values.
|
||||||
|
redact = types.ModuleType("homeassistant.helpers.redact")
|
||||||
|
redact.REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
|
def _stub_async_redact_data(data, to_redact):
|
||||||
|
if not isinstance(data, (Mapping, list)):
|
||||||
|
return data
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [_stub_async_redact_data(val, to_redact) for val in data]
|
||||||
|
redacted = {**data}
|
||||||
|
for key, value in redacted.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and not value:
|
||||||
|
continue
|
||||||
|
if key in to_redact:
|
||||||
|
if isinstance(to_redact, Mapping):
|
||||||
|
redacted[key] = to_redact[key](value)
|
||||||
|
else:
|
||||||
|
redacted[key] = redact.REDACTED
|
||||||
|
elif isinstance(value, Mapping):
|
||||||
|
redacted[key] = _stub_async_redact_data(value, to_redact)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
redacted[key] = [
|
||||||
|
_stub_async_redact_data(item, to_redact) for item in value
|
||||||
|
]
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
redact.async_redact_data = _stub_async_redact_data
|
||||||
|
helpers.redact = redact
|
||||||
|
sys.modules["homeassistant.helpers.redact"] = redact
|
||||||
|
|
||||||
sys.modules["homeassistant.helpers.event"] = event
|
sys.modules["homeassistant.helpers.event"] = event
|
||||||
sys.modules["homeassistant.helpers.entity"] = entity
|
sys.modules["homeassistant.helpers.entity"] = entity
|
||||||
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
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
|
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:
|
def load_entity_modules() -> dict:
|
||||||
"""Load the integration's HA-free-ish entity modules under nsh."""
|
"""Load the integration's HA-free-ish entity modules under nsh."""
|
||||||
install()
|
install()
|
||||||
@@ -355,6 +573,11 @@ def load_entity_modules() -> dict:
|
|||||||
"media_player",
|
"media_player",
|
||||||
"remote",
|
"remote",
|
||||||
"diagnostics",
|
"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",
|
||||||
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||||
if name == "init" and "nsh.init" in sys.modules:
|
if name == "init" and "nsh.init" in sys.modules:
|
||||||
|
|||||||
@@ -0,0 +1,931 @@
|
|||||||
|
"""Home-Assistant API-drift sweep (t37).
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
The unit suite runs our production modules against hand-written stubs
|
||||||
|
(``tests/entity_stublib.py`` plus the per-file stub fixtures), so a call site
|
||||||
|
that no longer matches the REAL Home Assistant API stays green. t36's sweep
|
||||||
|
found exactly that three times — two of them silent in production:
|
||||||
|
|
||||||
|
* **D1** ``from homeassistant.components.media_player import BrowseMediaSource``
|
||||||
|
— media_player has not exported that name for years (it lives in
|
||||||
|
``homeassistant.components.media_source.models``), so every real browse
|
||||||
|
raised ImportError;
|
||||||
|
* **D2** ``from homeassistant.util.diagnostic import async_redact_data`` — that
|
||||||
|
module does not exist at all, and the swallowed ImportError turned the
|
||||||
|
documented redaction pass into a no-op;
|
||||||
|
* **D3** ``self._async_handle_discovery_without_unique_id()`` — a coroutine
|
||||||
|
``ConfigFlow`` awaits in core; called without ``await`` its body never ran,
|
||||||
|
so HA's discovery dedup was dead and Python warned about the coroutine.
|
||||||
|
|
||||||
|
What it does
|
||||||
|
------------
|
||||||
|
``scan()`` parses every production module and resolves each Home-Assistant
|
||||||
|
dependency against an INSTALLED homeassistant:
|
||||||
|
|
||||||
|
1. every ``homeassistant.*`` import — module present, attribute present (or a
|
||||||
|
submodule of that name exists, which is what ``from x import submodule``
|
||||||
|
really means);
|
||||||
|
2. every keyword argument passed to a known HA API (see ``HA_CALL_OWNERS``)
|
||||||
|
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;
|
||||||
|
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:828``). 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.
|
||||||
|
|
||||||
|
Both AST passes run before the homeassistant availability check, and the file
|
||||||
|
walk is recursive (``_iter_python_files``), so a platform or helper one
|
||||||
|
directory down cannot hide from either of them.
|
||||||
|
|
||||||
|
Known limits (deliberate, and why the sweep still catches the real classes):
|
||||||
|
``**kwargs`` splats cannot be checked statically, calls on objects other than
|
||||||
|
``self`` (``self.hass.config_entries.…``) are only keyword-checked, and a
|
||||||
|
method our own class overrides is not resolved against HA. Those gaps are
|
||||||
|
visible in the ``Report`` counters, which the pytest wrapper asserts on so the
|
||||||
|
sweep cannot silently become vacuous.
|
||||||
|
|
||||||
|
The standalone entry point (``python tests/ha_api_drift.py [path …]``) exits
|
||||||
|
non-zero on findings *and* on counters below ``FLOORS`` — an empty or
|
||||||
|
mis-targeted directory is a failure, never a green sweep. ``FLOORS`` is the one
|
||||||
|
source of truth for both that entry point and the pytest wrapper, and the
|
||||||
|
interpreter floor Home Assistant itself declares lives here too
|
||||||
|
(``HOMEASSISTANT_PYTHON_FLOOR``), where CI's guard reads it instead of repeating
|
||||||
|
the number.
|
||||||
|
|
||||||
|
Behaviour note for users (t37 D3): MAC-less zeroconf discovery now follows Home
|
||||||
|
Assistant's stricter contract — the awaited
|
||||||
|
``_async_handle_discovery_without_unique_id`` aborts ``already_configured`` as
|
||||||
|
soon as ANY entry exists for this domain, so a second Shield that advertises no
|
||||||
|
MAC is added manually. See README "Discovery (zeroconf)".
|
||||||
|
|
||||||
|
Without homeassistant installed the sweep cannot resolve anything: ``scan()``
|
||||||
|
returns a single ``no-homeassistant`` finding, the counters stay at zero (so the
|
||||||
|
floors fail on purpose) and nothing is ever reported green. The pytest wrapper
|
||||||
|
(``tests/test_ha_api_drift.py``) skips cleanly in that case, and CI runs one job
|
||||||
|
with homeassistant installed for exactly this check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SHIELD_DIR = (
|
||||||
|
Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Attribute name -> (module, class) that owns the REAL signature. Resolved with
|
||||||
|
#: getattr at scan time, so a renamed or dropped API is a finding, not a crash.
|
||||||
|
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"),
|
||||||
|
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
|
||||||
|
"_abort_if_unique_id_configured": (
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"ConfigFlow",
|
||||||
|
),
|
||||||
|
"async_update_entry": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_schedule_reload": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_get_entry": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
|
||||||
|
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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.
|
||||||
|
FLOORS: dict[str, int] = {
|
||||||
|
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
||||||
|
# count (13) — a regression to the shallow glob trips it.
|
||||||
|
"modules": 20,
|
||||||
|
"imports": 40,
|
||||||
|
"relative_imports": 100,
|
||||||
|
"call_args": 20,
|
||||||
|
"awaited_calls": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
|
||||||
|
#: 2026.10.1 is ``Requires-Python >=3.14.2``, so a 3.13 runner cannot even
|
||||||
|
#: install the version this sweep must run against. CI's ``ha-api-drift`` job
|
||||||
|
#: provisions a matching interpreter and guards with THIS constant, so the
|
||||||
|
#: number is written once; tests/test_ha_api_drift.py pins it against the
|
||||||
|
#: installed distribution's own metadata.
|
||||||
|
HOMEASSISTANT_PYTHON_FLOOR: tuple[int, int, int] = (3, 14, 2)
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Finding:
|
||||||
|
"""One drifted API use."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
lineno: int
|
||||||
|
kind: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.path}:{self.lineno}: {self.kind}: {self.detail}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
"""Findings plus the counters that prove the sweep actually walked things."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
|
||||||
|
"""Counters below the non-vacuity floors — a sweep nobody should trust.
|
||||||
|
|
||||||
|
The floors default to module-level ``FLOORS`` so the standalone entry
|
||||||
|
point and the pytest wrapper cannot drift apart (t39 F1).
|
||||||
|
"""
|
||||||
|
floors = FLOORS if floors is None else floors
|
||||||
|
return [
|
||||||
|
f"only {getattr(self, name)} {name} (floor: {floor})"
|
||||||
|
for name, floor in floors.items()
|
||||||
|
if getattr(self, name) < floor
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if not self.findings:
|
||||||
|
return (
|
||||||
|
"no Home-Assistant API drift "
|
||||||
|
f"({self.modules} modules, {self.imports} imports, "
|
||||||
|
f"{self.relative_imports} relative imports, "
|
||||||
|
f"{self.call_args} call arguments, "
|
||||||
|
f"{self.awaited_calls} awaited HA calls)"
|
||||||
|
)
|
||||||
|
return "\n".join(str(finding) for finding in self.findings)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- 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:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as err: # noqa: BLE001 - the error IS the finding
|
||||||
|
return (
|
||||||
|
f"module {module_name!r} is not importable "
|
||||||
|
f"({type(err).__name__}: {err})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attr is None or hasattr(module, attr):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# `from package import submodule` resolves through the import system even
|
||||||
|
# when the attribute is not visible on the package yet.
|
||||||
|
try:
|
||||||
|
importlib.import_module(f"{module_name}.{attr}")
|
||||||
|
except Exception: # noqa: BLE001 - the error IS the finding
|
||||||
|
return f"{module_name}.{attr} does not exist"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
module_name = node.module or ""
|
||||||
|
if not module_name.startswith("homeassistant"):
|
||||||
|
continue
|
||||||
|
for alias in node.names:
|
||||||
|
report.imports += 1
|
||||||
|
reason = _check_import(module_name, alias.name)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
node.lineno,
|
||||||
|
"broken-import",
|
||||||
|
f"from {module_name} import {alias.name}: {reason}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if not alias.name.startswith("homeassistant"):
|
||||||
|
continue
|
||||||
|
report.imports += 1
|
||||||
|
reason = _check_import(alias.name, None)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
node.lineno,
|
||||||
|
"broken-import",
|
||||||
|
f"import {alias.name}: {reason}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- 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
|
||||||
|
|
||||||
|
|
||||||
|
def _real_signature(module_name: str, owner: str, attr: str):
|
||||||
|
"""(signature, reason) for an HA API; both None when uninspectable."""
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception as err: # noqa: BLE001 - the error IS the finding
|
||||||
|
return None, f"{module_name} is not importable ({type(err).__name__})"
|
||||||
|
|
||||||
|
owner_obj = getattr(module, owner, None)
|
||||||
|
if owner_obj is None:
|
||||||
|
return None, f"{module_name}.{owner} does not exist"
|
||||||
|
|
||||||
|
api = getattr(owner_obj, attr, None)
|
||||||
|
if api is None:
|
||||||
|
return None, f"{module_name}.{owner}.{attr} does not exist"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return inspect.signature(api), None
|
||||||
|
except (TypeError, ValueError): # pragma: no cover - C callables
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_call_keywords(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call) or not node.keywords:
|
||||||
|
continue
|
||||||
|
func = node.func
|
||||||
|
if not isinstance(func, ast.Attribute):
|
||||||
|
continue
|
||||||
|
owner = HA_CALL_OWNERS.get(func.attr)
|
||||||
|
if owner is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
signature, reason = _real_signature(*owner, func.attr)
|
||||||
|
if reason:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(path, node.lineno, "unknown-ha-api", reason)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if signature is None: # pragma: no cover - uninspectable
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
param.kind is inspect.Parameter.VAR_KEYWORD
|
||||||
|
for param in signature.parameters.values()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for keyword in node.keywords:
|
||||||
|
if keyword.arg is None: # **splat: not statically checkable
|
||||||
|
continue
|
||||||
|
report.call_args += 1
|
||||||
|
if keyword.arg not in signature.parameters:
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
path,
|
||||||
|
keyword.lineno,
|
||||||
|
"unknown-kwarg",
|
||||||
|
f"{func.attr}() has no {keyword.arg!r} parameter; "
|
||||||
|
f"real signature: {signature}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- await/sync
|
||||||
|
|
||||||
|
|
||||||
|
def _module_imports(tree: ast.AST) -> dict[str, tuple[str, str | None]]:
|
||||||
|
"""Local name -> (module, attribute-or-None) for the module's HA imports."""
|
||||||
|
imports: dict[str, tuple[str, str | None]] = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith(
|
||||||
|
"homeassistant"
|
||||||
|
):
|
||||||
|
for alias in node.names:
|
||||||
|
imports[alias.asname or alias.name] = (node.module, alias.name)
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name.startswith("homeassistant"):
|
||||||
|
imports[alias.asname or alias.name.split(".")[0]] = (
|
||||||
|
alias.name,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_base(expr: ast.AST, imports: dict[str, tuple[str, str | None]]):
|
||||||
|
"""Resolve a class base expression to (module, attribute) when it is HA's."""
|
||||||
|
if isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name):
|
||||||
|
target = imports.get(expr.value.id)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
module_name, attr = target
|
||||||
|
if attr is None:
|
||||||
|
# `import homeassistant.config_entries as config_entries`
|
||||||
|
return module_name, expr.attr
|
||||||
|
if module_name == "homeassistant":
|
||||||
|
# `from homeassistant import config_entries` binds the SUBMODULE,
|
||||||
|
# so `config_entries.ConfigFlow` is that module's attribute.
|
||||||
|
return f"{module_name}.{attr}", expr.attr
|
||||||
|
return None
|
||||||
|
if isinstance(expr, ast.Name):
|
||||||
|
target = imports.get(expr.id)
|
||||||
|
if target is not None and target[1] is not None:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_bases(
|
||||||
|
class_node: ast.ClassDef, imports: dict[str, tuple[str, str | None]]
|
||||||
|
) -> list[type]:
|
||||||
|
bases: list[type] = []
|
||||||
|
for expr in class_node.bases:
|
||||||
|
target = _resolve_base(expr, imports)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidate = getattr(importlib.import_module(target[0]), target[1])
|
||||||
|
except Exception: # noqa: BLE001 - unresolvable base, skip it
|
||||||
|
continue
|
||||||
|
if isinstance(candidate, type):
|
||||||
|
bases.append(candidate)
|
||||||
|
return bases
|
||||||
|
|
||||||
|
|
||||||
|
def _method_on_bases(bases: list[type], attr: str):
|
||||||
|
for base in bases:
|
||||||
|
found = getattr(base, attr, _MISSING)
|
||||||
|
if found is not _MISSING:
|
||||||
|
return found, base
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class _AwaitVisitor(ast.NodeVisitor):
|
||||||
|
"""Flags awaited/not-awaited mismatches for `self.<ha method>()` calls."""
|
||||||
|
|
||||||
|
def __init__(self, bases: list[type], path: str, report: Report) -> None:
|
||||||
|
self._bases = bases
|
||||||
|
self._path = path
|
||||||
|
self._report = report
|
||||||
|
self._in_await = 0
|
||||||
|
|
||||||
|
def visit_Await(self, node: ast.Await) -> None:
|
||||||
|
self._in_await += 1
|
||||||
|
self.generic_visit(node)
|
||||||
|
self._in_await -= 1
|
||||||
|
|
||||||
|
def visit_Call(self, node: ast.Call) -> None:
|
||||||
|
func = node.func
|
||||||
|
if (
|
||||||
|
isinstance(func, ast.Attribute)
|
||||||
|
and isinstance(func.value, ast.Name)
|
||||||
|
and func.value.id == "self"
|
||||||
|
):
|
||||||
|
api, owner = _method_on_bases(self._bases, func.attr)
|
||||||
|
if api is not None:
|
||||||
|
is_coro = inspect.iscoroutinefunction(api)
|
||||||
|
awaited = self._in_await > 0
|
||||||
|
where = f"{owner.__module__}.{owner.__qualname__}"
|
||||||
|
if is_coro and not awaited:
|
||||||
|
self._report.findings.append(
|
||||||
|
Finding(
|
||||||
|
self._path,
|
||||||
|
node.lineno,
|
||||||
|
"missing-await",
|
||||||
|
f"self.{func.attr}() is a coroutine on {where} — "
|
||||||
|
f"core awaits it; without `await` its body never runs",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif awaited and not is_coro:
|
||||||
|
self._report.findings.append(
|
||||||
|
Finding(
|
||||||
|
self._path,
|
||||||
|
node.lineno,
|
||||||
|
"awaits-sync-api",
|
||||||
|
f"self.{func.attr}() is not a coroutine on {where}, "
|
||||||
|
f"so awaiting it raises TypeError",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if awaited:
|
||||||
|
self._report.awaited_calls += 1
|
||||||
|
self.generic_visit(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_awaits(tree: ast.AST, path: str, report: Report) -> None:
|
||||||
|
imports = _module_imports(tree)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
bases = _ha_bases(node, imports)
|
||||||
|
if not bases:
|
||||||
|
continue
|
||||||
|
for statement in node.body:
|
||||||
|
# visit() walks the statement itself: a flat ast.walk would lose the
|
||||||
|
# `await` nesting and report every awaited call as un-awaited.
|
||||||
|
_AwaitVisitor(bases, path, report).visit(statement)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ floors and versioning
|
||||||
|
|
||||||
|
|
||||||
|
def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||||
|
"""Parse a ``>=X.Y[.Z]`` Requires-Python string into a version tuple.
|
||||||
|
|
||||||
|
The CI interpreter guard and the metadata pin in the pytest wrapper both
|
||||||
|
need the same reading of Home Assistant's own declaration.
|
||||||
|
"""
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
match = re.match(r"\s*>=\s*(\d+(?:\.\d+)*)", spec)
|
||||||
|
if match is None:
|
||||||
|
return 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:828``).
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- scan
|
||||||
|
|
||||||
|
|
||||||
|
def homeassistant_available() -> bool:
|
||||||
|
"""True when a real homeassistant is importable in this interpreter."""
|
||||||
|
return importlib.util.find_spec("homeassistant") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def scan(paths=None) -> Report:
|
||||||
|
"""Resolve every Home-Assistant API use in the production tree."""
|
||||||
|
if paths is None:
|
||||||
|
paths = [SHIELD_DIR]
|
||||||
|
elif isinstance(paths, (str, Path)):
|
||||||
|
paths = [paths]
|
||||||
|
|
||||||
|
report = Report()
|
||||||
|
# t40/t41: pure-AST checks, so they run even without homeassistant — that is
|
||||||
|
# the whole point of a contract stubs cannot enforce (and a relative import
|
||||||
|
# needs no library to be wrong).
|
||||||
|
_scan_entity_contracts(paths, report)
|
||||||
|
_scan_package_imports(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
|
||||||
|
# that it cannot judge and leaves the counters at zero — which trips the
|
||||||
|
# floors below on purpose.
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
"<environment>",
|
||||||
|
0,
|
||||||
|
"no-homeassistant",
|
||||||
|
"homeassistant is not installed in this interpreter, so nothing "
|
||||||
|
"can be resolved; install it (uvx --with homeassistant --python "
|
||||||
|
"3.14 ...) or run tests/test_ha_api_drift.py, which skips cleanly",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
for root in paths:
|
||||||
|
root = Path(root)
|
||||||
|
if not root.exists():
|
||||||
|
report.findings.append(
|
||||||
|
Finding(
|
||||||
|
str(root),
|
||||||
|
0,
|
||||||
|
"bad-path",
|
||||||
|
"path does not exist — nothing was scanned",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
files = _iter_python_files(root)
|
||||||
|
for file in files:
|
||||||
|
report.modules += 1
|
||||||
|
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||||
|
name = file.name
|
||||||
|
_scan_imports(tree, name, report)
|
||||||
|
_scan_call_keywords(tree, name, report)
|
||||||
|
_scan_awaits(tree, name, report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual red-proof entry point
|
||||||
|
import sys
|
||||||
|
|
||||||
|
targets = sys.argv[1:] or None
|
||||||
|
result = scan(targets)
|
||||||
|
print(result)
|
||||||
|
violations = result.floor_violations()
|
||||||
|
if result.findings or violations:
|
||||||
|
for line in violations:
|
||||||
|
print(f"FLOOR: {line}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f"drift sweep FAILED ({len(result.findings)} finding(s), "
|
||||||
|
f"{len(violations)} floor violation(s))",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
raise SystemExit(0)
|
||||||
+365
-46
@@ -6,12 +6,14 @@ legacy external entity, plus the §7 channel digit sequence (protocol digits,
|
|||||||
layer marks which tier served each command.
|
layer marks which tier served each command.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
import pytest
|
import pytest
|
||||||
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
from conftest import _SHIELD_DIR
|
||||||
|
from entity_stublib import install, load_entity_modules
|
||||||
from nsh.connection import ShieldConnectionManager
|
from nsh.connection import ShieldConnectionManager
|
||||||
from nsh.const import TIVIMATE_PACKAGE
|
from nsh.const import TIVIMATE_PACKAGE
|
||||||
|
|
||||||
@@ -19,15 +21,10 @@ from nsh.const import TIVIMATE_PACKAGE
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def scoped_entity_stubs():
|
def scoped_entity_stubs():
|
||||||
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
||||||
snapshot = {name: sys.modules.get(name) for name in ENTITIES_STUB_NAMES}
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
install()
|
install()
|
||||||
yield load_entity_modules()
|
yield load_entity_modules()
|
||||||
for name in ENTITIES_STUB_NAMES:
|
entity_stublib.restore_modules(snapshot)
|
||||||
original = snapshot[name]
|
|
||||||
if original is None:
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[name] = original
|
|
||||||
|
|
||||||
|
|
||||||
class FakeProtocolClient:
|
class FakeProtocolClient:
|
||||||
@@ -114,6 +111,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
player._entry = entry
|
player._entry = entry
|
||||||
player._manager = manager
|
player._manager = manager
|
||||||
player._intent = None
|
player._intent = None
|
||||||
|
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
|
||||||
player._last_channel = None
|
player._last_channel = None
|
||||||
player.hass = entity_stublib.FakeHass()
|
player.hass = entity_stublib.FakeHass()
|
||||||
player._entity_tasks = set()
|
player._entity_tasks = set()
|
||||||
@@ -121,13 +119,18 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
|||||||
player.async_write_ha_state = lambda: None
|
player.async_write_ha_state = lambda: None
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
|
|
||||||
|
# t41: nothing may leave this integration, so an HA service call is now the
|
||||||
|
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||||
|
# regression reintroduces the external-entity delegation.
|
||||||
served = []
|
served = []
|
||||||
|
|
||||||
async def _cmd(service, **kwargs):
|
class _RecordingServices:
|
||||||
served.append(("legacy", service))
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
return None
|
# t44: the payload matters now (command scripts pass HA script
|
||||||
|
# variables), so record it too — every existing assertion is `== []`.
|
||||||
|
served.append((domain, service, data))
|
||||||
|
|
||||||
player._cmd = _cmd
|
player.hass.services = _RecordingServices()
|
||||||
|
|
||||||
captured_intents = []
|
captured_intents = []
|
||||||
|
|
||||||
@@ -246,7 +249,8 @@ class TestChannelDigits:
|
|||||||
|
|
||||||
await player._channel_entry_sequence(["7"])
|
await player._channel_entry_sequence(["7"])
|
||||||
|
|
||||||
assert served == [("legacy", "play_media")] # transitional path used
|
# t41: both transports down is a no-op — no other integration is called
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
|
||||||
class TestAppAndUrls:
|
class TestAppAndUrls:
|
||||||
@@ -344,38 +348,67 @@ class TestRemoteEntity:
|
|||||||
assert protocol.key_calls == []
|
assert protocol.key_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntentRetryWithoutATransport:
|
||||||
|
"""t41: an intent no transport served surrenders instead of delegating."""
|
||||||
|
|
||||||
|
def _intent(self, **overrides):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
intent = SimpleNamespace(
|
||||||
|
target_state="playing",
|
||||||
|
command="media_play",
|
||||||
|
created_at=0.0,
|
||||||
|
prior_state=None,
|
||||||
|
retry_count=0,
|
||||||
|
via_adb=False,
|
||||||
|
via_protocol=False,
|
||||||
|
adb_command=None,
|
||||||
|
protocol_key=None,
|
||||||
|
)
|
||||||
|
for key, value in overrides.items():
|
||||||
|
setattr(intent, key, value)
|
||||||
|
return intent
|
||||||
|
|
||||||
|
async def test_unserved_intent_surrenders_without_a_service_call(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
player._intent = self._intent()
|
||||||
|
|
||||||
|
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||||
|
|
||||||
|
assert player._intent is None # surrendered to the truth
|
||||||
|
assert served == [] # and nothing left this integration
|
||||||
|
|
||||||
|
async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
player._intent = self._intent(
|
||||||
|
via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY"
|
||||||
|
)
|
||||||
|
|
||||||
|
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
|
||||||
class TestAppFallback:
|
class TestAppFallback:
|
||||||
async def test_app_id_prefers_the_androidtv_entity(self, modules):
|
"""t41: the app identity comes from OUR transports' store only."""
|
||||||
|
|
||||||
|
async def test_app_id_comes_from_the_manager_store(self, modules):
|
||||||
player, _s, _i = make_player(modules)
|
player, _s, _i = make_player(modules)
|
||||||
player._androidtv_entity = "media_player.atv"
|
player._manager._on_current_app("com.plex.hdx")
|
||||||
from types import SimpleNamespace as FakeState
|
|
||||||
|
|
||||||
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
|
|
||||||
states = FakeState(states=FakeState(
|
|
||||||
get=lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
))
|
|
||||||
player.hass = states
|
|
||||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
|
|
||||||
assert player.app_id == "com.plex.hdx"
|
|
||||||
assert player.app_name == "Plex"
|
|
||||||
|
|
||||||
async def test_app_falls_back_to_protocol_current_app(self, modules):
|
|
||||||
player, _s, _i = make_player(modules)
|
|
||||||
player._androidtv_entity = "" # standalone setup (P3-1)
|
|
||||||
player._manager._on_current_app("com.netflix.ninja")
|
|
||||||
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||||
|
|
||||||
assert player.app_id == "com.netflix.ninja"
|
assert player.app_id == "com.plex.hdx"
|
||||||
assert player.app_name == "com.netflix.ninja" # package is truth; map later
|
assert player.app_name == "com.plex.hdx" # package is truth; map later
|
||||||
|
|
||||||
async def test_app_unavailable_entity_falls_back(self, modules):
|
async def test_app_follows_the_latest_protocol_push(self, modules):
|
||||||
player, _s, _i = make_player(modules)
|
player, _s, _i = make_player(modules)
|
||||||
player._androidtv_entity = "media_player.atv"
|
player._manager._on_current_app("com.netflix.ninja")
|
||||||
from types import SimpleNamespace as FakeState
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
|
|
||||||
player._read = lambda eid: None
|
|
||||||
player._manager._on_current_app("com.google.android.youtube.tv")
|
player._manager._on_current_app("com.google.android.youtube.tv")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
@@ -391,7 +424,6 @@ def _ladder_player(modules, foreground=None, audio=None):
|
|||||||
manager.store.set("foreground", foreground)
|
manager.store.set("foreground", foreground)
|
||||||
if audio:
|
if audio:
|
||||||
manager.store.set("audio_playback", audio)
|
manager.store.set("audio_playback", audio)
|
||||||
player._androidtv_entity = ""
|
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
return player, manager
|
return player, manager
|
||||||
|
|
||||||
@@ -409,7 +441,6 @@ class TestForegroundLadderTier:
|
|||||||
manager.store.set("foreground", foreground)
|
manager.store.set("foreground", foreground)
|
||||||
if audio:
|
if audio:
|
||||||
manager.store.set("audio_playback", audio)
|
manager.store.set("audio_playback", audio)
|
||||||
player._androidtv_entity = ""
|
|
||||||
player._read = lambda eid: None
|
player._read = lambda eid: None
|
||||||
return player, manager
|
return player, manager
|
||||||
|
|
||||||
@@ -992,10 +1023,17 @@ class TestEntryDiagnostics:
|
|||||||
from types import SimpleNamespace as DiagEntry
|
from types import SimpleNamespace as DiagEntry
|
||||||
|
|
||||||
entry = DiagEntry(
|
entry = DiagEntry(
|
||||||
data={},
|
data={
|
||||||
|
"name": "Shield",
|
||||||
|
"androidtv_entity": "media_player.shield",
|
||||||
|
"nested": {"adb_host": "10.0.0.5"},
|
||||||
|
},
|
||||||
options={
|
options={
|
||||||
"adb_command_enabled": True,
|
"adb_command_enabled": True,
|
||||||
"protocol_pairing_code": "4821",
|
"protocol_pairing_code": "4821",
|
||||||
|
"host": "192.168.1.50", # t41: the one address field
|
||||||
|
"adb_host": "192.168.1.50", # legacy spellings stay redacted too
|
||||||
|
"protocol_host": "192.168.1.50",
|
||||||
},
|
},
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
@@ -1008,12 +1046,104 @@ class TestEntryDiagnostics:
|
|||||||
payload = await async_get_config_entry_diagnostics(None, entry)
|
payload = await async_get_config_entry_diagnostics(None, entry)
|
||||||
|
|
||||||
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
|
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
|
||||||
|
# t37 D2: the G14 field list (hosts included) really applies now — the
|
||||||
|
# old import pointed at homeassistant.util.diagnostic, which does not
|
||||||
|
# exist, so this pass silently did nothing and only the marker scan
|
||||||
|
# (pairing-/key-shaped names) masked anything.
|
||||||
|
assert payload["entry"]["options"]["host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["options"]["adb_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["data"]["androidtv_entity"] == "media_player.shield"
|
||||||
assert payload["entry"]["options"]["adb_command_enabled"] is True
|
assert payload["entry"]["options"]["adb_command_enabled"] is True
|
||||||
assert "awake" in payload["store"]
|
assert "awake" in payload["store"]
|
||||||
assert payload["transport"]["adb"]["connected"] is not None
|
assert payload["transport"]["adb"]["connected"] is not None
|
||||||
assert payload["transport"]["protocol"]["active"] is not None
|
assert payload["transport"]["protocol"]["active"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFaithfulHaStubs:
|
||||||
|
"""t37: the harness mirrors WHERE real HA exposes its API.
|
||||||
|
|
||||||
|
A regression of the exact drifts this task repairs must fail IN-SUITE, not
|
||||||
|
hide behind a stub that exposes anything anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_browse_media_source_is_not_importable_from_media_player(self):
|
||||||
|
import homeassistant.components.media_player as mp
|
||||||
|
|
||||||
|
assert isinstance(mp.BrowseMedia, type) # the real node class
|
||||||
|
assert not hasattr(mp, "BrowseMediaSource")
|
||||||
|
with pytest.raises(ImportError):
|
||||||
|
# the drifted import must fail here exactly as in a real install
|
||||||
|
from homeassistant.components.media_player import BrowseMediaSource # noqa: F401
|
||||||
|
|
||||||
|
def test_no_production_module_imports_browse_media_source(self):
|
||||||
|
"""t37 D1: the drift is an IMPORT — checked on the AST, not on prose."""
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and any(
|
||||||
|
alias.name == "BrowseMediaSource" for alias in node.names
|
||||||
|
):
|
||||||
|
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||||
|
if isinstance(node, ast.Import) and any(
|
||||||
|
alias.name.endswith("BrowseMediaSource") for alias in node.names
|
||||||
|
):
|
||||||
|
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
def test_the_browse_nodes_refuse_the_media_source_domain_kwarg(self):
|
||||||
|
import homeassistant.components.media_player as mp
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
mp.BrowseMedia(
|
||||||
|
title="x",
|
||||||
|
media_class=mp.MediaClass.APP,
|
||||||
|
media_content_type=mp.MediaType.APP,
|
||||||
|
media_content_id="pkg",
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
domain="nvidia_shield", # G18 keyword of BrowseMediaSource only
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_redaction_uses_the_real_helper_module(self):
|
||||||
|
import homeassistant.helpers.redact as redact
|
||||||
|
import nsh.diagnostics as diagnostics
|
||||||
|
|
||||||
|
assert redact.REDACTED == "**REDACTED**"
|
||||||
|
# the module actually USES the helper from the real module path
|
||||||
|
assert diagnostics.async_redact_data is redact.async_redact_data
|
||||||
|
assert {"adb_host", "protocol_host"} <= diagnostics._TO_REDACT
|
||||||
|
assert diagnostics._REDACTED == redact.REDACTED
|
||||||
|
|
||||||
|
def test_no_production_module_imports_the_nonexistent_diagnostic_module(self):
|
||||||
|
"""t37 D2: `homeassistant.util.diagnostic` never existed."""
|
||||||
|
offenders = [
|
||||||
|
f"{path.name}:{node.lineno}"
|
||||||
|
for path in sorted(_SHIELD_DIR.glob("*.py"))
|
||||||
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
if isinstance(node, ast.ImportFrom)
|
||||||
|
and node.module == "homeassistant.util.diagnostic"
|
||||||
|
]
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
def test_diagnostics_has_no_silent_import_fallback(self):
|
||||||
|
"""The redaction import is at module scope — loud, not swallowed."""
|
||||||
|
tree = ast.parse(
|
||||||
|
(_SHIELD_DIR / "diagnostics.py").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
caught = [
|
||||||
|
handler
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Try)
|
||||||
|
for handler in node.handlers
|
||||||
|
if isinstance(handler.type, ast.Name)
|
||||||
|
and handler.type.id == "ImportError"
|
||||||
|
]
|
||||||
|
assert caught == []
|
||||||
|
|
||||||
|
|
||||||
class TestBrowseMediaDirectory:
|
class TestBrowseMediaDirectory:
|
||||||
"""P4-2c: the app directory root and its launch path."""
|
"""P4-2c: the app directory root and its launch path."""
|
||||||
|
|
||||||
@@ -1034,10 +1164,24 @@ class TestBrowseMediaDirectory:
|
|||||||
("com.netflix.ninja", "Netflix"),
|
("com.netflix.ninja", "Netflix"),
|
||||||
]
|
]
|
||||||
assert all(c.media_content_type == "app" for c in root.children)
|
assert all(c.media_content_type == "app" for c in root.children)
|
||||||
# t30 G18 (2026-05-20 blog): the domain parameter is present at
|
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||||
# every level of the tree
|
# media_content_id/-type at every level, the directory naming the class
|
||||||
assert root.domain == "nvidia_shield"
|
# of its children, and NO media_source `domain` (that parameter belongs
|
||||||
assert all(c.domain == "nvidia_shield" for c in root.children)
|
# to media_source.models.BrowseMediaSource, which also builds the
|
||||||
|
# media_source:// ids).
|
||||||
|
assert root.media_class == "directory"
|
||||||
|
assert root.media_content_id == "apps"
|
||||||
|
assert root.media_content_type == "apps"
|
||||||
|
assert root.children_media_class == "app"
|
||||||
|
assert not hasattr(root, "domain")
|
||||||
|
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||||
|
# ...and it serialises into the payload the frontend consumes
|
||||||
|
payload = root.as_dict()
|
||||||
|
assert payload["media_content_id"] == "apps"
|
||||||
|
assert payload["children_media_class"] == "app"
|
||||||
|
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||||
|
assert payload["children"][0]["media_content_type"] == "app"
|
||||||
|
assert payload["children"][0]["can_play"] is True
|
||||||
|
|
||||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||||
player, _s, _i = make_player(modules) # no apps map configured
|
player, _s, _i = make_player(modules) # no apps map configured
|
||||||
@@ -1059,3 +1203,178 @@ class TestBrowseMediaDirectory:
|
|||||||
await player.async_play_media("app", selected.media_content_id)
|
await player.async_play_media("app", selected.media_content_id)
|
||||||
|
|
||||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||||
|
|
||||||
|
|
||||||
|
class DownAdbPoller(FakeAdbPoller):
|
||||||
|
"""A CONFIGURED ADB poller that cannot reach the device (t40 repro)."""
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager_with(*, poller=None, protocol=None):
|
||||||
|
"""A manager with exactly these transports (None = not configured)."""
|
||||||
|
return ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, protocol=protocol, store=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_app_sensor(modules, manager, entry):
|
||||||
|
"""A Current App sensor over the given manager/entry (t40).
|
||||||
|
|
||||||
|
t41: the sensor reads the manager's store and nothing else, so the harness
|
||||||
|
hands it a hass whose state machine is empty — a value here can only come
|
||||||
|
from our own transports.
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None))
|
||||||
|
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentAppSensorFriendlyName:
|
||||||
|
"""t40: the sensor shows the RESOLVED name; the raw package is an attribute.
|
||||||
|
|
||||||
|
It used to publish ``manager.foreground_app`` — the raw package id — so a
|
||||||
|
live install showed "ar.tvplayer.tv" where the media player showed
|
||||||
|
"TiviMate". Both now resolve through the shared ``resolve_app_name`` chain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _sensor(self, modules, player):
|
||||||
|
# The sensor resolves the manager through entry.runtime_data — the same
|
||||||
|
# wiring a real setup gives it.
|
||||||
|
player._entry.runtime_data = player._manager
|
||||||
|
return make_app_sensor(modules, player._manager, player._entry)
|
||||||
|
|
||||||
|
def test_mapped_package_shows_the_friendly_name(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
|
||||||
|
assert sensor.native_value == "TiviMate"
|
||||||
|
assert sensor.extra_state_attributes["app_id"] == "ar.tvplayer.tv"
|
||||||
|
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
|
||||||
|
|
||||||
|
def test_cached_manifest_label_is_the_second_tier(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock())
|
||||||
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
|
manager._on_current_app("com.netflix.ninja")
|
||||||
|
|
||||||
|
assert sensor.native_value == "Netflix"
|
||||||
|
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
|
||||||
|
|
||||||
|
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||||
|
manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}])
|
||||||
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
|
assert sensor.native_value == "TiviMate"
|
||||||
|
|
||||||
|
def test_unknown_package_falls_back_to_the_package_text(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
|
player._manager._on_current_app("com.example.unknown")
|
||||||
|
|
||||||
|
assert sensor.native_value == "com.example.unknown" # not None
|
||||||
|
assert sensor.extra_state_attributes == {
|
||||||
|
"app_id": "com.example.unknown",
|
||||||
|
"app_name": "com.example.unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_sensor_and_media_player_resolve_identically(self, modules):
|
||||||
|
"""One chain, two entities — they cannot disagree about the app name."""
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
sensor = self._sensor(modules, player)
|
||||||
|
|
||||||
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
|
||||||
|
assert sensor.native_value == player.app_name == "TiviMate"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAvailabilityLadder:
|
||||||
|
"""t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE.
|
||||||
|
|
||||||
|
The t5-F2 rule took the whole entity down whenever a configured ADB manager
|
||||||
|
was disconnected, even though the paired protocol transport was still
|
||||||
|
serving power/app/volume.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _player(self, modules, *, poller=None, protocol=None):
|
||||||
|
player, _served, _intents = make_player(
|
||||||
|
modules, manager=make_manager_with(poller=poller, protocol=protocol)
|
||||||
|
)
|
||||||
|
player._entry.runtime_data = player._manager
|
||||||
|
return player
|
||||||
|
|
||||||
|
def test_the_users_case_keeps_the_entity_available(self, modules):
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||||
|
player._manager._on_is_available(True)
|
||||||
|
|
||||||
|
assert player._manager.available is True # ADB is configured ...
|
||||||
|
assert player._manager.connected is False # ... but unreachable
|
||||||
|
assert player.available is True # the protocol carries the entity
|
||||||
|
|
||||||
|
def test_unavailable_when_every_configured_transport_is_down(self, modules):
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||||
|
player._manager._on_is_available(False)
|
||||||
|
|
||||||
|
assert player.available is False
|
||||||
|
|
||||||
|
def test_fresh_wake_data_is_enough(self, modules):
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||||
|
player._manager._on_is_available(False)
|
||||||
|
player._manager.store.set("awake", False)
|
||||||
|
|
||||||
|
assert player.available is True
|
||||||
|
|
||||||
|
def test_a_connected_adb_carries_a_protocol_less_entry(self, modules):
|
||||||
|
player = self._player(modules, poller=FakeAdbPoller(), protocol=None)
|
||||||
|
|
||||||
|
assert player._manager.protocol_active is False
|
||||||
|
assert player.available is True
|
||||||
|
|
||||||
|
def test_a_passive_manager_stays_available(self, modules):
|
||||||
|
player = self._player(modules, poller=None, protocol=None)
|
||||||
|
|
||||||
|
assert player._manager.available is False # no ADB configured at all
|
||||||
|
assert player.available is True
|
||||||
|
|
||||||
|
def test_a_configured_but_down_transport_is_unavailable(self, modules):
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=None)
|
||||||
|
|
||||||
|
assert player.available is False
|
||||||
|
|
||||||
|
def test_the_remote_follows_the_same_ladder(self, modules):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||||
|
player._manager._on_is_available(True)
|
||||||
|
remote = modules["remote"].NvidiaShieldRemoteEntity(
|
||||||
|
SimpleNamespace(), player._entry
|
||||||
|
)
|
||||||
|
|
||||||
|
assert remote.available is True
|
||||||
|
|
||||||
|
def test_the_diagnostics_dump_explains_the_ladder(self, modules):
|
||||||
|
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||||
|
player._manager._on_is_available(True)
|
||||||
|
|
||||||
|
from nsh.diagnostics import async_get_config_entry_diagnostics
|
||||||
|
|
||||||
|
entry = player._entry
|
||||||
|
entry.data = {}
|
||||||
|
entry.options = {}
|
||||||
|
payload = asyncio.run(async_get_config_entry_diagnostics(None, entry))
|
||||||
|
|
||||||
|
transport = payload["transport"]
|
||||||
|
assert transport["entity_available"] is True
|
||||||
|
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
|
||||||
|
assert transport["protocol"]["active"] is True
|
||||||
@@ -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
|
import types
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
import pytest
|
import pytest
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
from nsh.const import (
|
from nsh.const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_HOST,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_POWER_ENTITY,
|
CONF_POWER_ENTITY,
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
@@ -127,11 +127,7 @@ def _install_import_stubs() -> None:
|
|||||||
helpers.device_registry = device_registry
|
helpers.device_registry = device_registry
|
||||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
entity_stublib.install_selector_faces(selector)
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
|
||||||
selector.SelectSelector = lambda *a, **k: None
|
|
||||||
selector.SelectSelectorConfig = lambda **k: ""
|
|
||||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
||||||
sys.modules["homeassistant.helpers.selector"] = selector
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
@@ -164,9 +160,8 @@ def _flow(user_input_host: str, monkeypatch=None):
|
|||||||
flow._user_data = {
|
flow._user_data = {
|
||||||
"name": "Nvidia Shield",
|
"name": "Nvidia Shield",
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_ADB_HOST: user_input_host,
|
CONF_HOST: user_input_host,
|
||||||
CONF_ADB_PORT: 5555,
|
CONF_ADB_PORT: 5555,
|
||||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
|
||||||
CONF_JELLYFIN_ENTITY: "",
|
CONF_JELLYFIN_ENTITY: "",
|
||||||
CONF_RECEIVER_ENTITY: "",
|
CONF_RECEIVER_ENTITY: "",
|
||||||
CONF_RECEIVER_INPUT: "",
|
CONF_RECEIVER_INPUT: "",
|
||||||
@@ -189,15 +184,10 @@ def scoped_stubs():
|
|||||||
teardown, so later tests never observe the fake homeassistant/voluptuous
|
teardown, so later tests never observe the fake homeassistant/voluptuous
|
||||||
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
(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()
|
_install_import_stubs()
|
||||||
yield
|
yield
|
||||||
for name in _STUB_NAMES:
|
entity_stublib.restore_modules(snapshot)
|
||||||
original = snapshot[name]
|
|
||||||
if original is None:
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[name] = original
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import sys
|
|||||||
import types
|
import types
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
import pytest
|
import pytest
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
from nsh.const import (
|
from nsh.const import (
|
||||||
@@ -18,7 +19,7 @@ from nsh.const import (
|
|||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_HOST,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_PAIRING_CODE,
|
CONF_PAIRING_CODE,
|
||||||
CONF_POWER_ENTITY,
|
CONF_POWER_ENTITY,
|
||||||
@@ -158,12 +159,24 @@ def _install_stubs() -> None:
|
|||||||
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||||
|
|
||||||
def _update_reload_and_abort(self, entry, **kwargs):
|
def _update_reload_and_abort(self, entry, **kwargs):
|
||||||
updates = kwargs.get("options_updates") or {}
|
# `options` REPLACES the entry options (the real signature); the merge
|
||||||
|
# form is still accepted for older callers.
|
||||||
|
updates = dict(kwargs.get("options_updates") or {})
|
||||||
|
updates.update(kwargs.get("options") or {})
|
||||||
options = entry.options
|
options = entry.options
|
||||||
for key, value in updates.items():
|
for key, value in updates.items():
|
||||||
options[key] = value
|
options[key] = value
|
||||||
|
if kwargs.get("unique_id"):
|
||||||
|
entry.unique_id = kwargs["unique_id"]
|
||||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
return self.async_abort(reason="reauth_successful")
|
reason = kwargs.get("reason")
|
||||||
|
if reason is None:
|
||||||
|
reason = (
|
||||||
|
"reconfigure_successful"
|
||||||
|
if getattr(self, "source", None) == "reconfigure"
|
||||||
|
else "reauth_successful"
|
||||||
|
)
|
||||||
|
return self.async_abort(reason=reason)
|
||||||
|
|
||||||
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
|
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
|
||||||
|
|
||||||
@@ -207,11 +220,7 @@ def _install_stubs() -> None:
|
|||||||
helpers.device_registry = device_registry
|
helpers.device_registry = device_registry
|
||||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
entity_stublib.install_selector_faces(selector)
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
|
||||||
selector.SelectSelector = lambda *a, **k: None
|
|
||||||
selector.SelectSelectorConfig = lambda **k: ""
|
|
||||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
||||||
sys.modules["homeassistant.helpers.selector"] = selector
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
@@ -266,15 +275,10 @@ def _entry_module():
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def scoped_stubs():
|
def scoped_stubs():
|
||||||
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
"""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()
|
_install_stubs()
|
||||||
yield
|
yield
|
||||||
for name in _STUB_NAMES:
|
entity_stublib.restore_modules(snapshot)
|
||||||
original = snapshot[name]
|
|
||||||
if original is None:
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[name] = original
|
|
||||||
|
|
||||||
|
|
||||||
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
||||||
@@ -284,17 +288,15 @@ def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtoco
|
|||||||
flow.hass = FakeHass(tmp_dir)
|
flow.hass = FakeHass(tmp_dir)
|
||||||
flow._user_data = {
|
flow._user_data = {
|
||||||
"name": "mdarcy",
|
"name": "mdarcy",
|
||||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
|
||||||
CONF_JELLYFIN_ENTITY: "",
|
CONF_JELLYFIN_ENTITY: "",
|
||||||
CONF_RECEIVER_ENTITY: "",
|
CONF_RECEIVER_ENTITY: "",
|
||||||
CONF_RECEIVER_INPUT: "",
|
CONF_RECEIVER_INPUT: "",
|
||||||
CONF_POWER_ENTITY: "",
|
CONF_POWER_ENTITY: "",
|
||||||
CONF_ADB_ENABLED: False,
|
CONF_ADB_ENABLED: False,
|
||||||
CONF_ADB_STORE_APP_INFO: True,
|
CONF_ADB_STORE_APP_INFO: True,
|
||||||
CONF_ADB_HOST: "",
|
CONF_HOST: "shield.local", # t41: ONE address field
|
||||||
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: "shield.local",
|
|
||||||
}
|
}
|
||||||
from nsh.connection import ShieldConnectionManager
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
|
||||||
@@ -338,7 +340,7 @@ class TestProtocolPairing:
|
|||||||
assert client.disconnected == 1
|
assert client.disconnected == 1
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
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):
|
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
|
||||||
client = FakeProtocolClient()
|
client = FakeProtocolClient()
|
||||||
@@ -362,7 +364,7 @@ class TestReauthFlow:
|
|||||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
entry_id="e1", title="mdarcy",
|
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={},
|
data={},
|
||||||
)
|
)
|
||||||
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
||||||
@@ -384,12 +386,25 @@ class TestReauthFlow:
|
|||||||
|
|
||||||
|
|
||||||
class TestEntryMigration:
|
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()
|
module = _entry_module()
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
version=1,
|
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",
|
title="mdarcy",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -399,26 +414,82 @@ class TestEntryMigration:
|
|||||||
# ADB options carried over byte-compatibly
|
# ADB options carried over byte-compatibly
|
||||||
assert entry.options["adb_enabled"] is True
|
assert entry.options["adb_enabled"] is True
|
||||||
assert entry.options["adb_port"] == 5555
|
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_ENABLED] is False
|
||||||
assert entry.options[CONF_PROTOCOL_HOST] == ""
|
assert entry.options[CONF_HOST] == "10.0.0.31"
|
||||||
assert hass.config_entries.updating_entry_calls == [
|
assert "adb_host" not in entry.options
|
||||||
(entry, {"options": entry.options, "version": 2})
|
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()
|
module = _entry_module()
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
version=2,
|
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",
|
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 await module.async_migrate_entry(hass, entry) is True
|
||||||
assert hass.config_entries.updating_entry_calls == []
|
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()
|
module = _entry_module()
|
||||||
hass = FakeHass(str(tmp_path))
|
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
|
assert await module.async_migrate_entry(hass, entry) is False
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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")) == []
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"""Run the Home-Assistant API-drift sweep (t37).
|
||||||
|
|
||||||
|
Skips cleanly when homeassistant is not installed — every other test file runs
|
||||||
|
against hand-written stubs, and this is the one check that must see the real
|
||||||
|
package. CI's `ha-api-drift` job installs it:
|
||||||
|
|
||||||
|
uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
pytest.importorskip(
|
||||||
|
"homeassistant",
|
||||||
|
reason="the API-drift sweep resolves our call sites against a real install",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The three t36 drifts plus the `options_updates` kwarg t35 removed — the
|
||||||
|
#: sweep must flag every one of them, or it cannot protect us from the next.
|
||||||
|
PRE_FIX_SOURCES = {
|
||||||
|
"d1_browse_media_source.py": (
|
||||||
|
"from homeassistant.components.media_player import BrowseMediaSource\n"
|
||||||
|
),
|
||||||
|
"d2_util_diagnostic.py": (
|
||||||
|
"from homeassistant.util.diagnostic import async_redact_data\n"
|
||||||
|
),
|
||||||
|
"d3_unawaited_discovery.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||||
|
" self._async_handle_discovery_without_unique_id()\n"
|
||||||
|
),
|
||||||
|
"t35_options_updates.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_reauth_confirm(self, user_input=None):\n"
|
||||||
|
" self.async_update_reload_and_abort(\n"
|
||||||
|
" self.entry, options_updates={'protocol_enabled': True}\n"
|
||||||
|
" )\n"
|
||||||
|
" return self.async_abort(reason='reauth_successful')\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_installed_homeassistant():
|
||||||
|
"""Skip when sys.modules holds a test stub rather than an install."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
module = sys.modules["homeassistant"]
|
||||||
|
path = getattr(module, "__file__", None)
|
||||||
|
if path is None or not Path(path).exists():
|
||||||
|
pytest.skip("homeassistant in sys.modules is a test stub, not an install")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_api_drift_in_the_production_tree():
|
||||||
|
"""The gate: every HA import, kwarg and await matches the real package."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
report = ha_api_drift.scan()
|
||||||
|
|
||||||
|
assert report.findings == [], "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_is_not_vacuous():
|
||||||
|
"""The counters prove the walk really covered the production tree."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
report = ha_api_drift.scan()
|
||||||
|
|
||||||
|
# Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source
|
||||||
|
# of truth for this wrapper and the standalone entry point (t39 F1) — so a
|
||||||
|
# broken or mis-targeted walk fails here instead of looking green.
|
||||||
|
violations = report.floor_violations()
|
||||||
|
|
||||||
|
assert violations == [], "\n".join(violations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
|
||||||
|
"""Red proof: the t36 drifts and the options_updates kwarg are all caught."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
for name, source in PRE_FIX_SOURCES.items():
|
||||||
|
(tmp_path / name).write_text(source, encoding="utf-8")
|
||||||
|
|
||||||
|
report = ha_api_drift.scan(tmp_path)
|
||||||
|
|
||||||
|
found = {(finding.path, finding.kind) for finding in report.findings}
|
||||||
|
assert found == {
|
||||||
|
("d1_browse_media_source.py", "broken-import"),
|
||||||
|
("d2_util_diagnostic.py", "broken-import"),
|
||||||
|
("d3_unawaited_discovery.py", "missing-await"),
|
||||||
|
("t35_options_updates.py", "unknown-kwarg"),
|
||||||
|
}, "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_accepts_the_fixed_forms(tmp_path):
|
||||||
|
"""The same call sites written the way production writes them are clean."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
(tmp_path / "fixed.py").write_text(
|
||||||
|
"from homeassistant.components.media_player import BrowseMedia\n"
|
||||||
|
"from homeassistant.helpers.redact import async_redact_data\n"
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
||||||
|
" await self._async_handle_discovery_without_unique_id()\n"
|
||||||
|
"\n"
|
||||||
|
" def finish(self):\n"
|
||||||
|
" return self.async_update_reload_and_abort(\n"
|
||||||
|
" self.entry, options={'protocol_enabled': True}\n"
|
||||||
|
" )\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = ha_api_drift.scan(tmp_path)
|
||||||
|
|
||||||
|
assert report.findings == [], "\n" + str(report)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_drift_module(*paths):
|
||||||
|
"""Run the standalone entry point in this (HA-bearing) interpreter."""
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(Path(ha_api_drift.__file__).resolve()),
|
||||||
|
*[str(path) for path in paths],
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_passes_on_the_production_tree():
|
||||||
|
"""F1: the CLI is the sweep, with the same verdict on the real tree."""
|
||||||
|
result = _run_drift_module()
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "no Home-Assistant API drift" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path):
|
||||||
|
"""t39 F1: zeros must not exit 0 — the floors apply to the CLI too."""
|
||||||
|
result = _run_drift_module(tmp_path)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "0 modules" in result.stdout # the counter that used to look green
|
||||||
|
assert "FLOOR:" in result.stderr
|
||||||
|
assert "drift sweep FAILED" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path):
|
||||||
|
"""A mis-specified path is a failure, not a silent zero-module sweep."""
|
||||||
|
result = _run_drift_module(tmp_path / "not-a-directory")
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "bad-path" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_floor_constants_are_duplicated_in_this_wrapper():
|
||||||
|
"""F1: the floors come from ha_api_drift.FLOORS — nowhere else."""
|
||||||
|
source = Path(__file__).read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
|
||||||
|
local_ints = [
|
||||||
|
target.id
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.Assign)
|
||||||
|
for target in node.targets
|
||||||
|
if isinstance(target, ast.Name)
|
||||||
|
and isinstance(node.value, ast.Constant)
|
||||||
|
and isinstance(node.value.value, int)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}"
|
||||||
|
assert "floor_violations" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_declared_python_floor_never_lags_homeassistant():
|
||||||
|
"""F2: the CI guard's floor is HA's own Requires-Python, not a guess."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
import importlib.metadata as metadata
|
||||||
|
|
||||||
|
declared = metadata.metadata("homeassistant")["Requires-Python"]
|
||||||
|
parsed = ha_api_drift.parse_requires_python(declared)
|
||||||
|
|
||||||
|
assert parsed is not None, f"unparsable Requires-Python: {declared!r}"
|
||||||
|
assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, (
|
||||||
|
f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} "
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
("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"
|
||||||
|
)
|
||||||
@@ -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)
|
@pytest.fixture(autouse=True)
|
||||||
def _integration_module():
|
def _integration_module():
|
||||||
"""HA stubs + the integration __init__ module under nsh.init."""
|
"""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()
|
entity_stublib.install()
|
||||||
if "nsh.init" not in sys.modules:
|
if "nsh.init" not in sys.modules:
|
||||||
import conftest
|
import conftest
|
||||||
|
|
||||||
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
yield sys.modules["nsh.init"]
|
yield sys.modules["nsh.init"]
|
||||||
for name in _INIT_STUB_NAMES:
|
entity_stublib.restore_modules(snapshot)
|
||||||
original = snapshot[name]
|
|
||||||
if original is None:
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[name] = original
|
|
||||||
|
|
||||||
|
|
||||||
class _SeedEntriesStub:
|
class _SeedEntriesStub:
|
||||||
@@ -119,8 +114,9 @@ class TestYamlSeedBlocks:
|
|||||||
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
||||||
|
|
||||||
def test_block_normalized(self, _integration_module):
|
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(
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||||
)
|
)
|
||||||
@@ -128,20 +124,31 @@ class TestYamlSeedBlocks:
|
|||||||
assert len(blocks) == 1
|
assert len(blocks) == 1
|
||||||
seed = blocks[0]
|
seed = blocks[0]
|
||||||
assert seed["name"] == "Living"
|
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[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||||
assert seed["adb_enabled"] is True
|
assert seed["adb_enabled"] is True
|
||||||
assert "unique_id" not in seed # optional key only
|
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):
|
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||||
blocks = _integration_module._yaml_seed_blocks(
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(blocks) == 1
|
assert len(blocks) == 1
|
||||||
# protocol fields are prefill hints ONLY (pairing stays interactive)
|
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
|
||||||
assert blocks[0]["protocol_host"] == "shield.local"
|
assert blocks[0]["host"] == "shield.local"
|
||||||
assert blocks[0]["adb_port"] == 5000
|
assert blocks[0]["adb_port"] == 5000
|
||||||
|
assert blocks[0]["adb_enabled"] is False
|
||||||
|
|
||||||
def test_list_and_single_block_shapes(self, _integration_module):
|
def test_list_and_single_block_shapes(self, _integration_module):
|
||||||
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||||
@@ -166,7 +173,23 @@ class TestSeeding:
|
|||||||
domain, context, data = hass.config_entries.flows[0]
|
domain, context, data = hass.config_entries.flows[0]
|
||||||
assert domain == "nvidia_shield"
|
assert domain == "nvidia_shield"
|
||||||
assert context["source"] == "import"
|
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):
|
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||||
hass = _SeedHass(entries=[])
|
hass = _SeedHass(entries=[])
|
||||||
@@ -229,12 +252,12 @@ class TestImportStep:
|
|||||||
return flow
|
return flow
|
||||||
|
|
||||||
async def test_ui_configured_host_never_re_seeded(self):
|
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(
|
existing = SimpleNamespace(
|
||||||
entry_id="ui1",
|
entry_id="ui1",
|
||||||
unique_id=None,
|
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)
|
# options live on the entry (conf zeroconf guard reads them)
|
||||||
flow = self._flow([existing])
|
flow = self._flow([existing])
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
|
||||||
|
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
|
||||||
|
for app_map in (
|
||||||
|
None, # the curated default
|
||||||
|
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
|
||||||
|
):
|
||||||
|
player, _served = _player(modules, app_map=app_map)
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
assert player.source_list == [child.title for child in root.children]
|
||||||
|
|
||||||
|
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._app_labels["com.netflix.ninja"] = (
|
||||||
|
"Netflix",
|
||||||
|
player._manager._clock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BLANKED = {
|
||||||
|
"com.blank": "",
|
||||||
|
TIVIMATE: "TiviMate",
|
||||||
|
"com.spaces": " ",
|
||||||
|
"": "Ghost",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
|
||||||
|
"""The Browse tree and the dropdown are the same, resolved offer."""
|
||||||
|
player, _served = _player(modules, app_map=dict(self.BLANKED))
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
assert [child.title for child in root.children] == ["TiviMate"]
|
||||||
|
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||||
|
assert player.source_list == ["TiviMate"]
|
||||||
|
|
||||||
|
async def test_a_blank_package_is_dropped_too(self, modules):
|
||||||
|
"""An entry with no package is a child that cannot be launched."""
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||||
|
assert 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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
# display and match key are one string, everywhere
|
||||||
|
assert player.source_list == ["TiviMate", "Netflix"]
|
||||||
|
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
|
||||||
|
# ...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 "}
|
||||||
|
)
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
# both are still offered in the tree, each by its own package id
|
||||||
|
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||||
|
("com.a", "Netflix"),
|
||||||
|
("com.b", "Netflix"),
|
||||||
|
]
|
||||||
|
# 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||||
|
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
|
||||||
|
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||||
|
|
||||||
|
|
||||||
|
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 Browse tree still holds both apps, each under its own id
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||||
|
("com.a", "Dup"),
|
||||||
|
("com.b", "dup"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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,63 @@
|
|||||||
|
"""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
|
||||||
@@ -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() == ""
|
||||||
+263
-196
@@ -8,21 +8,25 @@ migration validity for v2 entries without the field.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio # noqa: F401 (async steps need the event loop)
|
import asyncio # noqa: F401 (async steps need the event loop)
|
||||||
|
import gc
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
import warnings
|
||||||
from pathlib import Path # noqa: E402 (F1 test key staging)
|
from pathlib import Path # noqa: E402 (F1 test key staging)
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
import pytest
|
import pytest
|
||||||
|
import voluptuous_mirror as vol_mirror
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
from nsh.const import (
|
from nsh.const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_HOST,
|
||||||
CONF_PROTOCOL_ENABLED,
|
CONF_PROTOCOL_ENABLED,
|
||||||
CONF_PROTOCOL_HOST,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_STUB_NAMES = (
|
_STUB_NAMES = (
|
||||||
@@ -41,6 +45,19 @@ _STUB_NAMES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryDedupAbortError(Exception):
|
||||||
|
"""Stands in for data_entry_flow.AbortFlow inside the discovery stub.
|
||||||
|
|
||||||
|
HA's `ConfigFlow._async_handle_discovery_without_unique_id` RAISES on a
|
||||||
|
duplicate (the flow manager turns that into an abort result); modelling it
|
||||||
|
as a raise is what makes an un-awaited call fail loudly here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason):
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
def _install_stubs() -> None:
|
def _install_stubs() -> None:
|
||||||
"""Import-time stand-ins (idempotent; same shape as the pairing tests).
|
"""Import-time stand-ins (idempotent; same shape as the pairing tests).
|
||||||
|
|
||||||
@@ -54,9 +71,10 @@ def _install_stubs() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
vol = types.ModuleType("voluptuous")
|
vol = types.ModuleType("voluptuous")
|
||||||
vol.Required = lambda key, default=None: key
|
# t33 F-A: a faithful mirror — the default stays ON the key, so a schema
|
||||||
vol.Optional = lambda key, default=None: key
|
# whose host default was dropped FAILS here instead of looking identical
|
||||||
vol.Schema = lambda schema: schema
|
# to one that carries it (the old bare-key stub hid exactly that bug).
|
||||||
|
vol_mirror.install(vol)
|
||||||
sys.modules["voluptuous"] = vol
|
sys.modules["voluptuous"] = vol
|
||||||
|
|
||||||
ha = types.ModuleType("homeassistant")
|
ha = types.ModuleType("homeassistant")
|
||||||
@@ -71,12 +89,16 @@ def _install_stubs() -> None:
|
|||||||
kwargs.pop("domain", None)
|
kwargs.pop("domain", None)
|
||||||
super().__init_subclass__(**kwargs)
|
super().__init_subclass__(**kwargs)
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
"type": "form",
|
"type": "form",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"errors": dict(errors or {}),
|
"errors": dict(errors or {}),
|
||||||
"data_schema": data_schema,
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
@@ -95,11 +117,23 @@ def _install_stubs() -> None:
|
|||||||
def _async_current_entries(self):
|
def _async_current_entries(self):
|
||||||
return _zc_holder.entries
|
return _zc_holder.entries
|
||||||
|
|
||||||
# t32 F2: the audited helper for uniqueness-less discovery — a
|
def _async_in_progress(self, include_uninitialized=False, **kwargs):
|
||||||
# recording no-op here; the flow's own host guard does the stacking
|
return _zc_holder.in_progress
|
||||||
# prevention the acceptance pins.
|
|
||||||
def _async_handle_discovery_without_unique_id(self):
|
# t37 D3: HA's own helper, modelled faithfully — a COROUTINE that
|
||||||
|
# dedups uniqueness-less discovery by raising. An un-awaited call
|
||||||
|
# therefore does nothing at all, which is exactly the production bug
|
||||||
|
# this replaces.
|
||||||
|
async def _async_handle_discovery_without_unique_id(self):
|
||||||
_zc_holder.discovery_without_unique_id_calls += 1
|
_zc_holder.discovery_without_unique_id_calls += 1
|
||||||
|
if getattr(self, "_unique_id", None) is not None:
|
||||||
|
return
|
||||||
|
if self._async_current_entries():
|
||||||
|
raise DiscoveryDedupAbortError("already_configured")
|
||||||
|
await self.async_set_unique_id(_zc_holder.discovery_unique_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
if self._async_in_progress(include_uninitialized=True):
|
||||||
|
raise DiscoveryDedupAbortError("already_in_progress")
|
||||||
|
|
||||||
class OptionsFlow:
|
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):
|
||||||
@@ -134,7 +168,12 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
# Shared holder so tests can swap the current-entry list through the
|
# Shared holder so tests can swap the current-entry list through the
|
||||||
# module object (the stub closure reads this same object).
|
# module object (the stub closure reads this same object).
|
||||||
_zc_holder = types.SimpleNamespace(entries=[], discovery_without_unique_id_calls=0)
|
_zc_holder = types.SimpleNamespace(
|
||||||
|
entries=[],
|
||||||
|
in_progress=[],
|
||||||
|
discovery_unique_id="default_discovery_unique_id",
|
||||||
|
discovery_without_unique_id_calls=0,
|
||||||
|
)
|
||||||
sys.modules[__name__]._zc_holder = _zc_holder
|
sys.modules[__name__]._zc_holder = _zc_holder
|
||||||
|
|
||||||
class AbortIfConfiguredError(Exception):
|
class AbortIfConfiguredError(Exception):
|
||||||
@@ -176,11 +215,7 @@ def _install_stubs() -> None:
|
|||||||
helpers.device_registry = device_registry
|
helpers.device_registry = device_registry
|
||||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
selector.EntitySelector = lambda *a, **k: None
|
entity_stublib.install_selector_faces(selector)
|
||||||
selector.EntitySelectorConfig = lambda *a, **k: ""
|
|
||||||
selector.SelectSelector = lambda *a, **k: None
|
|
||||||
selector.SelectSelectorConfig = lambda **k: ""
|
|
||||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
|
||||||
sys.modules["homeassistant.helpers.selector"] = selector
|
sys.modules["homeassistant.helpers.selector"] = selector
|
||||||
|
|
||||||
|
|
||||||
@@ -224,15 +259,10 @@ class FakeHass:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def scoped_stubs():
|
def scoped_stubs():
|
||||||
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
"""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()
|
_install_stubs()
|
||||||
yield
|
yield
|
||||||
for name in _STUB_NAMES:
|
entity_stublib.restore_modules(snapshot)
|
||||||
original = snapshot[name]
|
|
||||||
if original is None:
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
else:
|
|
||||||
sys.modules[name] = original
|
|
||||||
|
|
||||||
|
|
||||||
def _form_module():
|
def _form_module():
|
||||||
@@ -251,12 +281,21 @@ def _entry_module():
|
|||||||
return sys.modules["nsh.entry"]
|
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:
|
def _user_input(**overrides) -> dict:
|
||||||
base = {
|
base = {
|
||||||
"name": "mdarcy",
|
"name": "mdarcy",
|
||||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
|
||||||
CONF_PROTOCOL_ENABLED: False,
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
CONF_PROTOCOL_HOST: "",
|
CONF_HOST: "",
|
||||||
|
CONF_ADB_ENABLED: False,
|
||||||
}
|
}
|
||||||
base.update(overrides)
|
base.update(overrides)
|
||||||
return base
|
return base
|
||||||
@@ -268,21 +307,25 @@ class TestStandaloneValidation:
|
|||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
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["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
assert result["errors"] == {"base": "no_state_source"}
|
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()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
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"
|
fields = vol_mirror.declared_fields(result["data_schema"])
|
||||||
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
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):
|
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
@@ -307,29 +350,28 @@ class TestStandaloneValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await flow.async_step_user(
|
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["type"] == "form"
|
||||||
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||||
|
|
||||||
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
|
||||||
"""No androidtv entity → the key is absent from entry data (not "")."""
|
"""t41: every value lives in options; `data` carries no leftovers."""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
flow._user_data = {
|
flow._user_data = {
|
||||||
"name": "mdarcy",
|
"name": "mdarcy",
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
CONF_PROTOCOL_HOST: "shield.local",
|
CONF_HOST: "shield.local",
|
||||||
}
|
}
|
||||||
|
|
||||||
# skip the pairing route: drive _create_entry directly
|
# skip the pairing route: drive _create_entry directly
|
||||||
result = flow._create_entry()
|
result = flow._create_entry()
|
||||||
|
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
assert result["data"] == {}
|
||||||
|
|
||||||
|
|
||||||
class TestAdbOnlyStandalone:
|
class TestAdbOnlyStandalone:
|
||||||
@@ -342,7 +384,6 @@ class TestAdbOnlyStandalone:
|
|||||||
result = await flow.async_step_user(
|
result = await flow.async_step_user(
|
||||||
_user_input(
|
_user_input(
|
||||||
**{
|
**{
|
||||||
CONF_ANDROIDTV_ENTITY: "",
|
|
||||||
CONF_PROTOCOL_ENABLED: False,
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
"adb_enabled": True,
|
"adb_enabled": True,
|
||||||
}
|
}
|
||||||
@@ -360,54 +401,79 @@ class TestAdbOnlyStandalone:
|
|||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
result = await flow.async_step_user(
|
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"}
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
|
|
||||||
|
|
||||||
class TestMigrationKeepsOrAllowsAbsence:
|
class TestMigrationDropsTheEntityKey:
|
||||||
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
|
"""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()
|
module = _entry_module()
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
version=1,
|
version=1,
|
||||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
options={},
|
options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
title="mdarcy",
|
title="mdarcy",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
ok = await module.async_migrate_entry(hass, entry)
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
assert ok is True
|
assert ok is True
|
||||||
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
|
assert CONF_ANDROIDTV_ENTITY not in entry.data
|
||||||
# options were rewritten with the protocol defaults; the data dict —
|
assert CONF_ANDROIDTV_ENTITY not in entry.options
|
||||||
# including the androidtv field — was never touched
|
assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
|
||||||
assert hass.config_entries.updating_entry_calls == [
|
assert entry.data == {}
|
||||||
(
|
# the note names what was dropped, so the log is actionable
|
||||||
entry,
|
notes = [record.getMessage() for record in caplog.records]
|
||||||
{
|
assert any("media_player.shield" in note for note in notes), notes
|
||||||
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
|
assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
|
||||||
"version": 2,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
async def test_v2_without_the_field_is_valid(self, tmp_path):
|
async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
|
||||||
"""Standalone entries born v2 must pass through migration untouched."""
|
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()
|
module = _entry_module()
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
version=2,
|
version=2,
|
||||||
data={},
|
data={},
|
||||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
|
||||||
title="mdarcy",
|
title="mdarcy",
|
||||||
)
|
)
|
||||||
|
|
||||||
ok = await module.async_migrate_entry(hass, entry)
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
assert ok is True
|
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
|
||||||
|
|
||||||
|
|
||||||
def _cmd_player(modules):
|
def _cmd_player(modules):
|
||||||
@@ -479,6 +545,31 @@ class TestAppIconColumn:
|
|||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["errors"] == {"base": "invalid_app_map"}
|
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 flow.async_step_init(
|
||||||
|
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_app_map"}
|
||||||
|
assert "app_name_map" not in flow._options # nothing half-stored
|
||||||
|
|
||||||
|
|
||||||
class TestChannelEntryOptions:
|
class TestChannelEntryOptions:
|
||||||
"""P5-2: digit-delay + optional send-enter on the options page."""
|
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||||
@@ -529,69 +620,33 @@ class TestChannelEntryOptions:
|
|||||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
class TestUniqueIdSourceG2:
|
class TestUniqueIdSource:
|
||||||
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
"""t41: no unique_id is derived from another integration, at any stage.
|
||||||
|
|
||||||
def test_entity_unique_id_fallback_is_gone(self):
|
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
|
||||||
from pathlib import Path
|
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"
|
def test_no_entity_derived_unique_id_remains(self):
|
||||||
flow_src = (base / "config_flow.py").read_text()
|
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||||
# the historic renameable fallback is gone from the user step
|
|
||||||
|
assert "_unique_id_from_atv_device" not in flow_src
|
||||||
assert "async_set_unique_id(androidtv_entity)" 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
|
# no registry lookups for another integration's devices either
|
||||||
assert "_unique_id_from_atv_device" in flow_src
|
assert "device_registry" not 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."""
|
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
# a registered atv entity whose device carries a MAC
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
device = SimpleNamespace(
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
recorded = []
|
||||||
)
|
flow.async_set_unique_id = _record(recorded)
|
||||||
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
|
|
||||||
|
|
||||||
def async_get(self, device_id):
|
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
|
||||||
return self._by_id.get(device_id)
|
|
||||||
|
|
||||||
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
assert recorded == []
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestListenerComboGone:
|
class TestListenerComboGone:
|
||||||
@@ -778,10 +833,10 @@ class TestIpChangeSelfHealF1:
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
entry_id="e1",
|
entry_id="e1",
|
||||||
title="Shield",
|
title="Shield",
|
||||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
data={},
|
||||||
options={
|
options={
|
||||||
CONF_ADB_ENABLED: True,
|
CONF_ADB_ENABLED: True,
|
||||||
CONF_ADB_HOST: host,
|
CONF_HOST: host, # t41: the entry's one address
|
||||||
CONF_ADB_PORT: 5555,
|
CONF_ADB_PORT: 5555,
|
||||||
},
|
},
|
||||||
runtime_data=None,
|
runtime_data=None,
|
||||||
@@ -818,11 +873,7 @@ class TestIpChangeSelfHealF1:
|
|||||||
|
|
||||||
# the config-flow zeroconf path updates the entry's hosts via
|
# the config-flow zeroconf path updates the entry's hosts via
|
||||||
# async_update_entry — the NEXT built poller targets the NEW address
|
# async_update_entry — the NEXT built poller targets the NEW address
|
||||||
entry.options = {
|
entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
|
||||||
**entry.options,
|
|
||||||
CONF_ADB_HOST: "192.168.1.77",
|
|
||||||
CONF_PROTOCOL_HOST: "192.168.1.77",
|
|
||||||
}
|
|
||||||
|
|
||||||
second = factory()
|
second = factory()
|
||||||
assert second is not None
|
assert second is not None
|
||||||
@@ -850,39 +901,75 @@ class TestMacLessDiscoveryF2:
|
|||||||
ip_address=host, host=host, properties=props or {}
|
ip_address=host, host=host, properties=props or {}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_mac_less_records_the_helper(self, tmp_path):
|
def _holder(self):
|
||||||
|
return sys.modules[__name__]._zc_holder
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_awaits_the_helper(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls = 0
|
self._holder().discovery_without_unique_id_calls = 0
|
||||||
|
|
||||||
result = await flow.async_step_zeroconf(self._discovery())
|
result = await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
assert result["type"] == "form" # prefill still offered once
|
assert result["type"] == "form" # prefill still offered once
|
||||||
assert (
|
# t37 D3: only an AWAITED coroutine body runs — before the fix this
|
||||||
sys.modules[__name__]._zc_holder.discovery_without_unique_id_calls
|
# counter stayed at 0 and Python warned "coroutine ... was never
|
||||||
== 1
|
# awaited", while HA's dedup never happened
|
||||||
)
|
assert self._holder().discovery_without_unique_id_calls == 1
|
||||||
|
# the helper claims HA's discovery sentinel for the flow
|
||||||
|
assert flow._unique_id == self._holder().discovery_unique_id
|
||||||
|
|
||||||
async def test_mac_less_rediscovery_does_not_stack(self, tmp_path):
|
async def test_mac_less_discovery_with_entries_aborts(self, tmp_path):
|
||||||
"""A second MAC-less discovery of an already-configured host
|
"""Real contract: any entry for the handler → already_configured."""
|
||||||
aborts — one-entry outcome."""
|
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
entry_id="e1",
|
entry_id="e1",
|
||||||
unique_id=None, # MAC-less entry
|
unique_id=None, # MAC-less entry
|
||||||
options={CONF_PROTOCOL_HOST: "192.168.1.50"},
|
options={CONF_HOST: "192.168.1.50"},
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
self._holder().entries[:] = [entry]
|
||||||
|
|
||||||
result = await flow.async_step_zeroconf(self._discovery())
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
assert result == {"type": "abort", "reason": "already_configured"}
|
assert raised.value.reason == "already_configured"
|
||||||
# the entries list is untouched: one-entry outcome
|
# the entries list is untouched: one-entry outcome
|
||||||
assert len(sys.modules[__name__]._zc_holder.entries) == 1
|
assert len(self._holder().entries) == 1
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_while_another_flow_runs_aborts(self, tmp_path):
|
||||||
|
"""The other half of the real contract: one flow at a time."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
self._holder().in_progress[:] = [{"flow_id": "other"}]
|
||||||
|
|
||||||
|
with pytest.raises(DiscoveryDedupAbortError) as raised:
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
|
assert raised.value.reason == "already_in_progress"
|
||||||
|
|
||||||
|
async def test_mac_less_discovery_leaves_no_un_awaited_coroutine(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""t37 D3: the helper must never be left un-awaited."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
await flow.async_step_zeroconf(self._discovery())
|
||||||
|
del flow
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
never_awaited = [
|
||||||
|
str(w.message) for w in caught if "never awaited" in str(w.message)
|
||||||
|
]
|
||||||
|
assert never_awaited == []
|
||||||
|
|
||||||
|
|
||||||
class TestSeedBlockShapeF4:
|
class TestSeedBlockShapeF4:
|
||||||
@@ -902,7 +989,7 @@ class TestSeedBlockShapeF4:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert blocks[0]["adb_enabled"] is False
|
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):
|
def test_adb_block_keeps_adb_on(self):
|
||||||
import conftest
|
import conftest
|
||||||
@@ -942,6 +1029,11 @@ class TestZeroconfDiscovery:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
||||||
|
"""t33 F-A: the discovered host is the DEFAULT OF THE SCHEMA KEYS.
|
||||||
|
|
||||||
|
The old check (the attribute was stored) stayed green while the
|
||||||
|
rendered form was blank — this one reads the schema itself.
|
||||||
|
"""
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
flow.hass = FakeHass(str(tmp_path))
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
@@ -950,9 +1042,12 @@ class TestZeroconfDiscovery:
|
|||||||
|
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
# pre-fill landed: the discovered host is recorded for the schema
|
|
||||||
assert flow._discovered_host == "192.168.1.50"
|
assert flow._discovered_host == "192.168.1.50"
|
||||||
assert result["data_schema"] is not None
|
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
||||||
|
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
|
||||||
|
|
||||||
async def test_discovery_without_address_aborts(self, tmp_path):
|
async def test_discovery_without_address_aborts(self, tmp_path):
|
||||||
module = _form_module()
|
module = _form_module()
|
||||||
@@ -973,7 +1068,7 @@ class TestZeroconfDiscovery:
|
|||||||
|
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
unique_id="aa:bb:cc:dd:ee:ff",
|
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",
|
entry_id="e1",
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
@@ -984,9 +1079,8 @@ class TestZeroconfDiscovery:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"type": "abort", "reason": "already_configured"}
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
# the hosts moved to the discovery address
|
# t41: the ONE address field moved to the discovery address
|
||||||
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
assert entry.options[CONF_HOST] == "192.168.1.50"
|
||||||
assert entry.options["adb_host"] == "192.168.1.50"
|
|
||||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||||
|
|
||||||
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||||
@@ -995,14 +1089,10 @@ class TestZeroconfDiscovery:
|
|||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
flow.hass = hass
|
flow.hass = hass
|
||||||
|
|
||||||
# both hosts already hold the discovered address: nothing changes
|
# the address already holds the discovered value: nothing changes
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
unique_id="aa:bb:cc:dd:ee:ff",
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
options={
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"},
|
||||||
CONF_PROTOCOL_ENABLED: True,
|
|
||||||
CONF_PROTOCOL_HOST: "192.168.1.50",
|
|
||||||
"adb_host": "192.168.1.50",
|
|
||||||
},
|
|
||||||
entry_id="e1",
|
entry_id="e1",
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
@@ -1016,8 +1106,24 @@ class TestZeroconfDiscovery:
|
|||||||
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
||||||
assert hass.config_entries.reloaded_entry_ids == []
|
assert hass.config_entries.reloaded_entry_ids == []
|
||||||
|
|
||||||
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path):
|
async def test_the_per_host_heal_cases_collapsed_into_one_rule(self):
|
||||||
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery."""
|
"""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()
|
module = _form_module()
|
||||||
flow = module.NvidiaShieldConfigFlow()
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
hass = FakeHass(str(tmp_path))
|
hass = FakeHass(str(tmp_path))
|
||||||
@@ -1025,37 +1131,7 @@ class TestZeroconfDiscovery:
|
|||||||
|
|
||||||
entry = SimpleNamespace(
|
entry = SimpleNamespace(
|
||||||
unique_id="aa:bb:cc:dd:ee:ff",
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"},
|
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale
|
||||||
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
|
|
||||||
},
|
|
||||||
entry_id="e1",
|
entry_id="e1",
|
||||||
title="Shield",
|
title="Shield",
|
||||||
)
|
)
|
||||||
@@ -1067,8 +1143,7 @@ class TestZeroconfDiscovery:
|
|||||||
|
|
||||||
assert result == {"type": "abort", "reason": "already_configured"}
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||||
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
assert update[CONF_HOST] == "192.168.1.50"
|
||||||
assert update["adb_host"] == "10.0.0.9" # override untouched
|
|
||||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||||
|
|
||||||
|
|
||||||
@@ -1137,7 +1212,6 @@ class TestAppNameMapResolution:
|
|||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
player._androidtv_entity = "" # standalone
|
|
||||||
|
|
||||||
manager = player._manager
|
manager = player._manager
|
||||||
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||||
@@ -1152,7 +1226,6 @@ class TestAppNameMapResolution:
|
|||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
player._androidtv_entity = ""
|
|
||||||
|
|
||||||
manager = player._manager
|
manager = player._manager
|
||||||
manager._on_current_app("com.google.android.youtube.tv")
|
manager._on_current_app("com.google.android.youtube.tv")
|
||||||
@@ -1160,23 +1233,17 @@ class TestAppNameMapResolution:
|
|||||||
|
|
||||||
assert player.app_name == "com.google.android.youtube.tv"
|
assert player.app_name == "com.google.android.youtube.tv"
|
||||||
|
|
||||||
async def test_entity_tier_serves_when_no_higher_tier_matches(self):
|
async def test_cached_manifest_label_is_the_second_tier(self):
|
||||||
"""P5-1: the entity tier is below map/manifest — it serves when the
|
"""P5-1: below the explicit map sits the device manifest label."""
|
||||||
package has no explicit override and no manifest label yet."""
|
|
||||||
from types import SimpleNamespace as SimpleNS
|
|
||||||
|
|
||||||
import entity_stublib
|
import entity_stublib
|
||||||
|
|
||||||
modules = entity_stublib.load_entity_modules()
|
modules = entity_stublib.load_entity_modules()
|
||||||
player, _s, _i = _cmd_player(modules)
|
player, _s, _i = _cmd_player(modules)
|
||||||
player._entry.options["app_name_map"] = {} # no override
|
player._entry.options["app_name_map"] = {} # no override
|
||||||
player._androidtv_entity = "media_player.atv"
|
manager = player._manager
|
||||||
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||||
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
|
||||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
|
||||||
|
|
||||||
player._manager._on_current_app("ar.tvplayer.tv")
|
manager._on_current_app("ar.tvplayer.tv")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# no cached label yet → falls through to the entity tier
|
assert player.app_name == "TiviMate"
|
||||||
assert player.app_name == "ATV Tivi"
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""A voluptuous mirror faithful enough to SEE a dropped schema default.
|
||||||
|
|
||||||
|
The test environment never installs voluptuous (the config-flow tests import
|
||||||
|
``config_flow`` against import-only stubs), so the harness has to model the two
|
||||||
|
behaviours the flow schemas depend on:
|
||||||
|
|
||||||
|
* ``Optional(field, default=value)`` / ``Required(field, default=value)`` carry
|
||||||
|
that default on the KEY object;
|
||||||
|
* keys compare by FIELD NAME only — real ``voluptuous.Marker.__eq__`` is
|
||||||
|
``self.schema == other`` (no class check at all), so
|
||||||
|
``Optional("x") == Required("x")`` is True, and ``__hash__`` is
|
||||||
|
``hash(self.schema)``. A later re-declaration of the same field therefore
|
||||||
|
collides with the FIRST key object, so a default added by a second
|
||||||
|
declaration is silently dropped.
|
||||||
|
|
||||||
|
That second property is exactly how the t33 F-A discovery-prefill bug hid from
|
||||||
|
the suite: the old stubs reduced every key to its bare string, so a schema
|
||||||
|
whose host key carried no default looked identical to one that carried the
|
||||||
|
discovered address. ``TestVoluptuousMirrorContract`` in
|
||||||
|
``tests/test_config_flow_ux.py`` pins both properties so the harness itself
|
||||||
|
cannot regress into blindness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
UNDEFINED = object()
|
||||||
|
"""Sentinel for "this key declares no default" (real ``voluptuous.UNDEFINED``)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Marker:
|
||||||
|
"""Stand-in for ``voluptuous.Marker`` (the ``Required``/``Optional`` base)."""
|
||||||
|
|
||||||
|
def __init__(self, schema, default=UNDEFINED):
|
||||||
|
self.schema = schema
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
# real Marker hashes the field name only
|
||||||
|
return hash(self.schema)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
# real Marker.__eq__ is `self.schema == other`: the FIELD is the whole
|
||||||
|
# comparison — no class check, so Optional("x") == Required("x") (and
|
||||||
|
# even Marker("x") == "x") is True. That is what makes a re-declaration
|
||||||
|
# of a field collide with the first key object and drop its default.
|
||||||
|
return self.schema == other
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<{type(self).__name__} {self.schema!r} default={self.default!r}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Required(Marker):
|
||||||
|
"""Stand-in for ``voluptuous.Required``."""
|
||||||
|
|
||||||
|
|
||||||
|
class Optional(Marker):
|
||||||
|
"""Stand-in for ``voluptuous.Optional``."""
|
||||||
|
|
||||||
|
|
||||||
|
def install(vol) -> None:
|
||||||
|
"""Wire the mirror onto a stub ``voluptuous`` module object."""
|
||||||
|
vol.UNDEFINED = UNDEFINED
|
||||||
|
vol.Marker = Marker
|
||||||
|
vol.Required = Required
|
||||||
|
vol.Optional = Optional
|
||||||
|
vol.Schema = lambda schema: schema
|
||||||
|
|
||||||
|
|
||||||
|
def defaults_by_key(schema_dict) -> dict:
|
||||||
|
"""``{field: default}`` for a schema declared with mirror keys."""
|
||||||
|
return {key.schema: key.default for key in declared_keys(schema_dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def declared_keys(schema_dict) -> list:
|
||||||
|
"""The schema's marker keys, in declaration order.
|
||||||
|
|
||||||
|
Raises on a non-marker key: a bare string key means the harness (or the
|
||||||
|
code under test) lost the KEY structure this mirror exists to check.
|
||||||
|
Test-harness bookkeeping keys (``__suggested__`` and friends) are skipped.
|
||||||
|
"""
|
||||||
|
keys = []
|
||||||
|
for key in schema_dict:
|
||||||
|
if isinstance(key, Marker):
|
||||||
|
keys.append(key)
|
||||||
|
continue
|
||||||
|
if isinstance(key, str) and key.startswith("__"):
|
||||||
|
continue
|
||||||
|
raise AssertionError(f"schema key {key!r} is not a voluptuous marker")
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def declared_fields(schema_dict) -> list:
|
||||||
|
"""The field names of a schema, in declaration order."""
|
||||||
|
return [key.schema for key in declared_keys(schema_dict)]
|
||||||
Reference in new issue
Block a user