Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7540e6f4 | ||
|
|
5ae2baa618 | ||
|
|
181a51af95 | ||
|
|
431b7cd8f4 | ||
|
|
c884c7e1ab | ||
|
|
afc3326fc3 | ||
|
|
1c414c2f70 | ||
|
|
93d0afcdcf | ||
|
|
0ef72a1fbb | ||
|
|
c05e6fa929 | ||
|
|
ef42682250 | ||
|
|
ff8dd1c454 | ||
|
|
364d4b7da3 | ||
|
|
95d8d20571 | ||
|
|
3a55519b74 | ||
|
|
5eefd0c6d0 | ||
|
|
a56ec38170 | ||
|
|
ee3f2845af | ||
|
|
9461d69469 | ||
|
|
ca3fdec218 | ||
|
|
b0adbd2dfd | ||
|
|
cd8f528a27 | ||
|
|
74e0549196 | ||
|
|
85e96f3c60 | ||
|
|
0c7e405108 | ||
|
|
ccde3cff71 | ||
|
|
07682ff082 | ||
|
|
49bc23ae34 | ||
|
|
0357f47284 | ||
|
|
dc3cfbbe3e | ||
|
|
97615408c0 | ||
|
|
1c6c223f27 | ||
|
|
c3e4c7855c | ||
|
|
b2aab15ebf | ||
|
|
b3f897c36e | ||
|
|
0cfa045190 | ||
|
|
b03cfbae13 | ||
|
|
89c05f1dcd | ||
|
|
2e1d162ef4 | ||
|
|
fbc72dfd9e | ||
|
|
3175582419 | ||
|
|
7608518d97 | ||
|
|
aea9f9f9d2 | ||
|
|
130dee4bc6 | ||
|
|
151c49a0da | ||
|
|
fb481c3c14 | ||
|
|
81f70585cd | ||
|
|
75ebeb40b0 | ||
|
|
86aa2827a2 | ||
|
|
60d884b76a | ||
|
|
dd23d01a88 | ||
|
|
be3abae3e7 | ||
|
|
acc7bc3d01 | ||
|
|
c588a7c66b | ||
|
|
fd62d850b7 | ||
|
|
824d6192a1 | ||
|
|
60fb4f7557 | ||
|
|
bd72ae3170 | ||
|
|
37b2accfda | ||
|
|
5ede4fe722 | ||
|
|
e11dccf2c8 | ||
|
|
7a0152e6b5 | ||
|
|
186e5c1935 | ||
|
|
08b97fde73 | ||
|
|
a6cc56feb4 | ||
|
|
73e886dafe | ||
|
|
bbd98ebc3f | ||
|
|
60d426945b | ||
|
|
c20146b08b | ||
|
|
6087566336 | ||
|
|
8f40b6435c | ||
|
|
c1a82a2fa5 | ||
|
|
782d749b4a | ||
|
|
d004e73d99 | ||
|
|
e77bc12d81 | ||
|
|
80ddff313a | ||
|
|
297f8a21a7 | ||
|
|
a6eeae86e9 | ||
|
|
2e70587871 | ||
|
|
194a97ce34 | ||
|
|
7c12d96070 | ||
|
|
a5254b4740 | ||
|
|
383ec5ada5 | ||
|
|
e1a0fc3039 | ||
|
|
086c089cc1 | ||
|
|
bcb8b962e7 | ||
|
|
3e0208789a |
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}"
|
||||||
|
|||||||
+191
-2
@@ -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,12 @@
|
|||||||
# 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).
|
||||||
|
#
|
||||||
|
# Home Assistant is installed in exactly two jobs: `ha-api-drift` (below) and
|
||||||
|
# `real-ha-config-flow`. Both exist because every other job validates our code
|
||||||
|
# against hand-written stubs, which stay green even when a call site no longer
|
||||||
|
# matches the real API. That is how three drifts reached master (t36 D1/D2/D3) —
|
||||||
|
# see tests/ha_api_drift.py.
|
||||||
|
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
@@ -21,10 +27,193 @@ 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
|
||||||
|
|
||||||
|
# The real-HA config-flow tests (tests/test_config_flow_real_ha.py) are the
|
||||||
|
# ONLY place the config flow meets the real homeassistant.data_entry_flow
|
||||||
|
# machinery — and that file SKIPS ITSELF at import time when homeassistant is
|
||||||
|
# absent (pytest.importorskip at module scope). A skipped file and a passing
|
||||||
|
# one are indistinguishable in the jobs above: `python3 -m pytest tests -q`
|
||||||
|
# ends in "N passed, 1 skipped" and still exits 0, and the drift sweep never
|
||||||
|
# touches this file. That is exactly how three real-HA-only facts stayed
|
||||||
|
# hidden while five gates were green:
|
||||||
|
#
|
||||||
|
# * a menu row id must equal its async_step_<id> method name (otherwise the
|
||||||
|
# real FlowManager raises when the row is dispatched),
|
||||||
|
# * the rendered step_id must equal the row id, and
|
||||||
|
# * async_show_menu takes no errors kwarg.
|
||||||
|
#
|
||||||
|
# So this job does two things the others cannot: it installs the real stack on
|
||||||
|
# Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are
|
||||||
|
# skipped instead of run. Do not "simplify" the guard at the bottom away — a
|
||||||
|
# test file that skips silently is indistinguishable from one that passes.
|
||||||
|
real-ha-config-flow:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Provision the interpreter Home Assistant needs
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# Same floor as the ha-api-drift job: Home Assistant 2026.10.1
|
||||||
|
# declares Requires-Python >= 3.14.2 (tests/ha_api_drift.py holds our
|
||||||
|
# copy of that number). This resolves the newest 3.14.x patch.
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Ensure uv is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# uvx runs the exact command this gate was validated with locally;
|
||||||
|
# runner images are not guaranteed to ship it.
|
||||||
|
if ! command -v uvx >/dev/null 2>&1; then
|
||||||
|
python3 -m pip install --quiet uv \
|
||||||
|
|| python3 -m pip install --quiet --break-system-packages uv
|
||||||
|
fi
|
||||||
|
uvx --version
|
||||||
|
|
||||||
|
- name: Check the interpreter satisfies Home Assistant's floor
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The floor comes from tests/ha_api_drift.py — one source of truth for
|
||||||
|
# this guard and the metadata pin in tests/test_ha_api_drift.py. uvx
|
||||||
|
# resolves the same interpreter the test step will use, so a too-old
|
||||||
|
# interpreter fails here rather than halfway through the install.
|
||||||
|
uvx --python 3.14 python - <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path("tests").resolve()))
|
||||||
|
from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402
|
||||||
|
|
||||||
|
needed = ".".join(str(part) for part in FLOOR)
|
||||||
|
if sys.version_info < FLOOR:
|
||||||
|
print(
|
||||||
|
f"Home Assistant needs Python >= {needed}, but this job runs "
|
||||||
|
f"{sys.version.split()[0]}."
|
||||||
|
)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Real-HA config-flow tests
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
LOG=/tmp/real-ha-config-flow.log
|
||||||
|
# Verbatim the command this gate is validated with locally; -rs is what
|
||||||
|
# makes a silent skip visible in the log.
|
||||||
|
set +e
|
||||||
|
uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
|
||||||
|
--with pytest-asyncio \
|
||||||
|
pytest tests/test_config_flow_real_ha.py -q -rs 2>&1 | tee "$LOG"
|
||||||
|
rc=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo "::error::the real-HA config-flow tests exited $rc"
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# THE GUARD. Some pytest versions exit 0 for a file that skipped
|
||||||
|
# itself, so the exit code above is not enough. The last line of a -q
|
||||||
|
# run is the counts line ("26 passed in 1.6s" / "1 skipped in
|
||||||
|
# 0.01s"); it must report PASSED tests and nothing else.
|
||||||
|
summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1)
|
||||||
|
echo "pytest summary: $summary"
|
||||||
|
case "$summary" in
|
||||||
|
[0-9]*" passed"*) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::the real-HA config-flow tests did not run (summary: $summary)"
|
||||||
|
echo "The file skips itself when homeassistant/probatio is missing — a skip here is a FAILURE, not a pass."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case "$summary" in
|
||||||
|
*skipped*|*failed*|*error*|*"no tests ran"*)
|
||||||
|
echo "::error::the real-HA config-flow run was not a clean pass (summary: $summary)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -9,7 +9,9 @@ This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/l
|
|||||||
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
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).
|
||||||
@@ -61,28 +63,56 @@ remote protocol is the fast push/command path:
|
|||||||
|
|
||||||
1. **Plug off** — the power switch says `off` → `off`.
|
1. **Plug off** — the power switch says `off` → `off`.
|
||||||
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
||||||
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
|
3. **Intent** — a just-sent command's target state, self-healing against
|
||||||
4. **Intent** — a just-sent command's target state, self-healing against
|
|
||||||
reality within 30 s (retry goes over the transport that served it).
|
reality within 30 s (retry goes over the transport that served it).
|
||||||
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||||
6. **ADB media session** — `playing` / `paused` for apps that publish a
|
5. **ADB media session** — `playing` / `paused` / `buffering` for apps that
|
||||||
session (foreground app preferred).
|
publish a session (foreground app preferred). `buffering` is the media
|
||||||
7. **Foreground fallback** — an app is open (`current_app` protocol push,
|
session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the
|
||||||
then the ADB `mResumedActivity` parse) but publishes no media session →
|
stock-Android buffering value, **inferred** from that line shape: no captured
|
||||||
`on`. Its identity comes from the TTL-capped last-known-state cache, the
|
fixture here contains `state=6` and no real device was available. A buffering
|
||||||
last channel entered, or the zero-cost "audio playback" hint
|
session advertises the playback controls exactly like `playing`.
|
||||||
(`media recently active`) — never resurrecting `playing` past the 60 s
|
6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the
|
||||||
state TTL.
|
power plug on) and nothing is playing, or ADB reports it asleep (standby).
|
||||||
8. **On** — powered, awake, nothing more specific.
|
A powered Shield with an app open and nothing playing reads `idle`.
|
||||||
|
7. **On** — **no source reported a power or playback fact** (t58 UNIT 0; the
|
||||||
|
earlier wording said "no data", which was too loose). `on` is the "we cannot
|
||||||
|
know" state and the only situation it describes, so every source that
|
||||||
|
*answers* — including an answer of "not playing" — stops the ladder at
|
||||||
|
`idle` at worst, never here:
|
||||||
|
- a configured **Jellyfin** entity in **any** state: `idle`, `off`,
|
||||||
|
`standby` and `unavailable` all mean "its session is not playing", so they
|
||||||
|
report — and because the ladder keeps going, a silent Jellyfin still
|
||||||
|
cannot mask a live media session below it;
|
||||||
|
- a **live media session with no playback flag** — the app publishes a
|
||||||
|
session but is not playing, paused or buffering;
|
||||||
|
- a **bare foreground app** — the protocol's `current_app` push or the ADB
|
||||||
|
`mResumedActivity` parse answered;
|
||||||
|
- a configured **power entity**, whatever it currently says;
|
||||||
|
- protocol `is_on` / ADB awake, plugged or not.
|
||||||
|
`on` survives only for an entry where none of those exists, and it never
|
||||||
|
masks a `playing`/`paused`/`buffering` source.
|
||||||
|
|
||||||
|
The foreground tier is what keeps an app's identity visible while the state is
|
||||||
|
`idle`: the `current_app` protocol push (then the ADB `mResumedActivity`
|
||||||
|
parse), with its title from the TTL-capped last-known-state cache, the last
|
||||||
|
channel entered, or the zero-cost "audio playback" hint
|
||||||
|
(`media recently active`) — never resurrecting `playing` past the 60 s state
|
||||||
|
TTL.
|
||||||
|
|
||||||
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
||||||
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
|
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 5–6 plus
|
||||||
the audio hint are what keep it visible.
|
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
|
||||||
|
|
||||||
@@ -92,6 +122,43 @@ the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
|
|||||||
behavior). Both are consumed by `play_media` with
|
behavior). Both are consumed by `play_media` with
|
||||||
`media_content_type: channel`.
|
`media_content_type: channel`.
|
||||||
|
|
||||||
|
### YAML seeding (optional, seed-only)
|
||||||
|
|
||||||
|
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
||||||
|
interactive setup flow — nothing is created without the user walking the
|
||||||
|
standard flow, and pairing stays interactive exactly as with UI setup. The UI is
|
||||||
|
the primary path; the block exists so a provisioning/fleet setup can supply the
|
||||||
|
address and the transport choice.
|
||||||
|
|
||||||
|
A complete, copy-pasteable block (every key is optional except an address):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nvidia_shield:
|
||||||
|
- name: Living Room # the entry's title (default: NVIDIA Shield)
|
||||||
|
host: 10.0.0.31 # the Shield's IP — the one address field
|
||||||
|
adb_port: 5555 # optional; the standard ADB port
|
||||||
|
adb_enabled: true # optional; the block's shape decides (see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
|
||||||
|
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
|
||||||
|
the address is a prefill hint either way, and the PIN/ADB pairing still happens
|
||||||
|
in the flow. `adb_enabled` defaults to what the block implies — an address under
|
||||||
|
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
|
||||||
|
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
|
||||||
|
address are ignored with a warning.
|
||||||
|
|
||||||
|
The seed carries the address, the ADB toggle and the entry's name; everything
|
||||||
|
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
|
||||||
|
`adb_command` service) is set on the entry's **Configure** page after setup, or
|
||||||
|
during the interactive flow.
|
||||||
|
Duplicate guards: a block matching an existing entry's host (or its
|
||||||
|
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||||
|
host. Removing the YAML block creates or deletes nothing — entries live
|
||||||
|
in the storage registry and require user consent to remove. Deleting/re-
|
||||||
|
adding is the only way to move an imported entry between hosts; the UI is
|
||||||
|
the contractual path (YAML is the seed, the flow is the truth).
|
||||||
|
|
||||||
### Raw `adb_command` service (opt-in)
|
### Raw `adb_command` service (opt-in)
|
||||||
|
|
||||||
When enabled in the integration's options (`adb_command_enabled`, **default
|
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||||
@@ -104,24 +171,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` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
|
||||||
|
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) |
|
||||||
|
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
|
||||||
|
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
|
||||||
|
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
|
||||||
|
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
|
||||||
|
|
||||||
|
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
|
||||||
|
comes from the ADB media session — the same tier every other app uses — so it
|
||||||
|
needs no bridge either.
|
||||||
|
|
||||||
|
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
|
||||||
|
full retirement mapping this section summarises.
|
||||||
|
|
||||||
## Releases
|
## 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.)
|
||||||
|
|
||||||
@@ -147,10 +266,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
|
||||||
|
|
||||||
@@ -160,20 +284,322 @@ 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
|
||||||
|
|
||||||
|
The Configure page is a **menu of sub-screens**, not one long form. Home Assistant
|
||||||
|
renders it as a list of rows; each row opens a short form and comes back to the menu,
|
||||||
|
and only **Save and close** commits:
|
||||||
|
|
||||||
|
| Row | What it holds |
|
||||||
|
| --- | --- |
|
||||||
|
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch |
|
||||||
|
| **Receiver** | the receiver/soundbar entity and the input it uses |
|
||||||
|
| **Apps** | the apps table — one row per app (see below) |
|
||||||
|
| **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER |
|
||||||
|
| **Save and close** | writes the merged options and reloads the integration |
|
||||||
|
|
||||||
|
Every field that used to be on the single page is still there, under one of those
|
||||||
|
rows. Edits are merged **per key**, so visiting another sub-screen (or saving from a
|
||||||
|
later one) never drops what an earlier one changed. Half-finished pages are not
|
||||||
|
written: closing the dialog without **Save and close** leaves the entry as it was.
|
||||||
|
|
||||||
|
The **setup** flow follows the same shape: its first form is unchanged (name, host,
|
||||||
|
transports, the linked entities), and after it the flow is a menu — **Receiver**,
|
||||||
|
**Connection**, **Set up ADB**, **Pair the remote protocol**, **Create the entry** — so
|
||||||
|
anything you want to correct is one row away instead of buried in a form you have
|
||||||
|
already submitted. The two pairing rows appear only while that transport still needs
|
||||||
|
pairing (a skip leaves its row there to retry; a successful pairing removes it), and
|
||||||
|
the menu's description names what is still to pair.
|
||||||
|
|
||||||
|
### The Apps table
|
||||||
|
|
||||||
|
The **Apps** row is an editable table. One app per line, in the documented format:
|
||||||
|
|
||||||
|
```
|
||||||
|
ar.tvplayer.tv=TiviMate
|
||||||
|
com.plex.hdx=Plex|http://192.168.1.10:9000/plex.png
|
||||||
|
com.netflix.ninja=Netflix
|
||||||
|
```
|
||||||
|
|
||||||
|
`package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or
|
||||||
|
a `camera.*` entity id — see *Sources* below). The field always opens carrying the
|
||||||
|
current rows, so adding, renaming or deleting an app is an edit to that text:
|
||||||
|
|
||||||
|
- **add a row** — type a new line
|
||||||
|
- **edit a row** — change its name, icon or package id
|
||||||
|
- **remove a row** — delete its line
|
||||||
|
|
||||||
|
A blank field clears the table (and the integration falls back to its built-in app
|
||||||
|
list). A malformed row — no `=`, or an empty name — is rejected with an error on the
|
||||||
|
page rather than being half-saved.
|
||||||
|
|
||||||
|
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
|
||||||
|
speaker that only knows on and off is a valid receiver. Playback then **ensures
|
||||||
|
that switch is on**: `turn_on`, `play_media` and `select_source` call
|
||||||
|
`switch.turn_on` when — and only when — it reports `off`, and touch nothing else
|
||||||
|
about it (never volume, never a toggle). An already-on switch gets no call at all,
|
||||||
|
a switch whose state cannot be read (`unavailable`/`unknown`) is left alone rather
|
||||||
|
than guessed at, and a receiver that is off while the Shield is idle stays off.
|
||||||
|
That is what makes receiver **volume scripts unnecessary** for this shape: there is
|
||||||
|
no volume to script, only an on/off state to ensure. A `media_player.*` receiver
|
||||||
|
keeps its own behaviour — input selection and attribute enrichment.
|
||||||
|
|
||||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||||
- **Receiver + Input** — for media-zone coordination
|
- **Receiver + Input** — for media-zone coordination; `media_player` or `switch`
|
||||||
- **Power Switch** — smart plug entity
|
- **Power Switch** — smart plug entity
|
||||||
- **ADB Host/Port** — override auto-detection if needed
|
- **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
|
||||||
|
transport on or off
|
||||||
|
- **Shield address / ADB port** — the Shield's IP, and the ADB port if you
|
||||||
|
changed it from 5555
|
||||||
|
- **Command scripts** — one optional script per command, plus one for the remote
|
||||||
|
entity; see below
|
||||||
|
|
||||||
|
### Command scripts (per-command overrides)
|
||||||
|
|
||||||
|
Some commands need hardware this integration deliberately does not drive: a speaker
|
||||||
|
switch that owns the volume, an AVR input that has to move with the Shield, a mute
|
||||||
|
that has to hit two devices. For those, the Configure page takes a **script per
|
||||||
|
command**:
|
||||||
|
|
||||||
|
| Command | Runs when | Variables passed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `turn_on`, `turn_off` | turn on/off, from the media player *or* the remote | — |
|
||||||
|
| `volume_up`, `volume_down` | volume step | — |
|
||||||
|
| `volume_set` | absolute volume (there is no native path — the protocol has VOLUME_UP/DOWN/MUTE only) | `level` |
|
||||||
|
| `volume_mute` | mute/unmute | `is_volume_muted` |
|
||||||
|
| `select_source` | source selection (the app list drives it natively; a script overrides) | `app_name` |
|
||||||
|
|
||||||
|
A command with a script calls the script **instead of** the protocol/ADB path, never
|
||||||
|
both. Left unset — the default — every command behaves exactly as it did before, and
|
||||||
|
clearing a selector on the Configure page restores that. Two of the seven appear as
|
||||||
|
services only when there is something to do: `volume_set`, which has no native path at
|
||||||
|
all, and `select_source`, which is offered once the integration has sources to offer
|
||||||
|
(the app list below) — or a script.
|
||||||
|
|
||||||
|
The scripts are ordinary Home Assistant scripts. This block covers all three awkward
|
||||||
|
cases: a speaker switch owning the volume, an AVR owning the input, and a mute that
|
||||||
|
has to hit the switch as well.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
script:
|
||||||
|
shield_volume: # select as "Volume set script"
|
||||||
|
alias: Shield volume (speaker switch)
|
||||||
|
sequence:
|
||||||
|
- action: media_player.volume_set
|
||||||
|
target:
|
||||||
|
entity_id: media_player.living_room_speaker
|
||||||
|
data:
|
||||||
|
volume_level: "{{ level }}"
|
||||||
|
shield_source: # select as "Select source script"
|
||||||
|
alias: Shield source (AVR)
|
||||||
|
sequence:
|
||||||
|
- action: media_player.select_source
|
||||||
|
target:
|
||||||
|
entity_id: media_player.avr
|
||||||
|
data:
|
||||||
|
source: "{{ app_name }}"
|
||||||
|
shield_mute: # select as "Mute script"
|
||||||
|
alias: Shield mute (speaker switch)
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ is_volume_muted }}"
|
||||||
|
sequence:
|
||||||
|
- action: switch.turn_off
|
||||||
|
target:
|
||||||
|
entity_id: switch.living_room_speaker
|
||||||
|
default:
|
||||||
|
- action: switch.turn_on
|
||||||
|
target:
|
||||||
|
entity_id: switch.living_room_speaker
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set `script.shield_volume` as **Volume set script**, `script.shield_source` as
|
||||||
|
**Select source script** and so on on the Configure page. Scripts are set there, not
|
||||||
|
in the YAML seed above — the seed only carries the address and the transport choice.
|
||||||
|
|
||||||
|
A script that fails surfaces as a translated error on the service call that triggered
|
||||||
|
it (the command is not retried over protocol/ADB), because a silent no-op is the one
|
||||||
|
outcome that leaves you guessing.
|
||||||
|
|
||||||
|
### App discovery (what is actually installed)
|
||||||
|
|
||||||
|
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
|
||||||
|
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu
|
||||||
|
and the table is rebuilt from two ADB queries:
|
||||||
|
|
||||||
|
| Command | What it returns | How it is treated |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pm list packages -3` | every **third-party** (user-installed) package, one `package:<id>` per line | all of them become rows |
|
||||||
|
| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended |
|
||||||
|
|
||||||
|
System apps are **not** enumerated — a Shield ships a few hundred of them and they are
|
||||||
|
not launchable apps — with the one exception that matters: the active launcher, which
|
||||||
|
is a system app yet is exactly what the user sees. Nothing else is filtered, so an app
|
||||||
|
is missing only if Android itself does not report it.
|
||||||
|
|
||||||
|
*(Every output shape above is the **documented** Android one, not an observed
|
||||||
|
capture: no real Shield was available when this was written, so
|
||||||
|
`pm list packages -3`, `resolve-activity --brief` and `pm dump` are exercised from
|
||||||
|
fixtures — the same "inferred, not observed" mark the state=6 buffering value
|
||||||
|
carries in the source ladder above.)*
|
||||||
|
|
||||||
|
Names come from the same cached path the Current App sensor uses: a `pm dump` per
|
||||||
|
package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps
|
||||||
|
in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in
|
||||||
|
seconds instead of the minutes a strictly serialised sweep would take; the commands
|
||||||
|
share the watchdog's own ADB connection, and `adb-shell` serialises the I/O on it, so
|
||||||
|
the burst queues politely instead of interleaving on the socket. A label fetch
|
||||||
|
that **fails is not cached**, so that app shows its package id in this table and the
|
||||||
|
next sweep tries again — this is the same rule as the entity path, and it is why a
|
||||||
|
transient ADB hiccup no longer sticks.
|
||||||
|
|
||||||
|
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
|
||||||
|
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
|
||||||
|
there is no device icon to read. Each app gets a curated `package → mdi:` icon where we
|
||||||
|
know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin —
|
||||||
|
and `mdi:television` otherwise. The icon never enters your **icon column**: that column
|
||||||
|
is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an
|
||||||
|
`mdi:` name there would put a value the media player cannot use into your own map.
|
||||||
|
|
||||||
|
Discovery does not run on the watchdog: the Configure row runs it, the dashboard
|
||||||
|
sensor refreshes the table when it has no fresh one, and the result is cached for 6 h.
|
||||||
|
With ADB off, or unreachable, the table simply shows the rows you wrote yourself and
|
||||||
|
the menu says why. A sweep that **could not run** is not cached as an answer: it is
|
||||||
|
recorded as `unknown` and retried, so a Shield that was asleep at startup is picked up
|
||||||
|
by the next refresh instead of reporting an empty device for six hours.
|
||||||
|
|
||||||
|
**For dashboards** the same table is a sensor: `sensor.<name>_installed_apps`, whose
|
||||||
|
state is the app **count** and whose `installed_apps` attribute is the list of
|
||||||
|
`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `unknown`,
|
||||||
|
`not_connected` or `adb_disabled`: `ok` means the table IS the answer — including a
|
||||||
|
genuinely empty one — and `unknown` means the last sweep could not run (an empty list
|
||||||
|
then says nothing about the device, and the next refresh retries). It only exists for
|
||||||
|
ADB-enabled entries, and it is cheap to refresh — `homeassistant.update_entity` on it
|
||||||
|
re-runs the sweep only when there is no fresh table (the cache expired, or the last
|
||||||
|
sweep could not run), while the Configure row forces one.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# every installed app, with the name this integration would show
|
||||||
|
{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }}
|
||||||
|
| map(attribute='name') | list }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sources (the app list in a dropdown)
|
||||||
|
|
||||||
|
The media player's source list is your **Apps** map — the same map app-name resolution
|
||||||
|
uses. With no map configured it falls back to a small built-in list
|
||||||
|
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||||
|
something to show. Discovery fills the **Configure** table, not this fallback: the
|
||||||
|
dropdown stays exactly the map you saved, so what a card offers is always what you
|
||||||
|
chose. An app that discovery found and you want in the dropdown is one row to add.
|
||||||
|
|
||||||
|
**Apps are sources, not browser media.** This integration no longer publishes the app
|
||||||
|
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
|
||||||
|
this entity (the device name, no rows) instead of a second, inconsistent copy of the
|
||||||
|
same list — the user report was *"Media Browser shows Apps. To me it should probably be
|
||||||
|
integrated with the home assistant media display, keep select sources for apps"*.
|
||||||
|
Everything an app row could do lives in `source_list` and `media_player.select_source`
|
||||||
|
below, and nothing else on this entity is browsable, so there is no dead entry to click
|
||||||
|
and nothing else the browser could show.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# a media-player card, or anything that reads source_list
|
||||||
|
type: media-control
|
||||||
|
entity: media_player.living_room_shield
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`source_list`** — the app names. They come from the map (in the order it lists
|
||||||
|
them), so editing the map renames the dropdown entries.
|
||||||
|
- **`source`** — the app currently on screen, resolved exactly like the Current App
|
||||||
|
sensor: your map first, then the cached device label, then the raw package.
|
||||||
|
- **`media_player.select_source`** — picks one **by name** and launches it over the
|
||||||
|
protocol, the same path `play_media` uses for apps. A raw package id works too (that
|
||||||
|
holds the package id), names match case-insensitively, and a name that
|
||||||
|
is not in the list is an error rather than a silent no-op:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
action: media_player.select_source
|
||||||
|
target:
|
||||||
|
entity_id: media_player.living_room_shield
|
||||||
|
data:
|
||||||
|
source: TiviMate
|
||||||
|
```
|
||||||
|
|
||||||
|
Selecting a source is sometimes more than launching an app — an AVR input, a speaker
|
||||||
|
switch. That is what the **Select source script** above is for: with one configured it
|
||||||
|
replaces the native launch entirely.
|
||||||
|
|
||||||
|
#### Known limits (the Apps map)
|
||||||
|
|
||||||
|
The map is your own override, so two shapes are accepted rather than papered over:
|
||||||
|
|
||||||
|
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
|
||||||
|
row — the first spelling — because name matching is case-insensitive. The shadowed
|
||||||
|
app is not lost: `media_player.select_source` accepts its own package id.
|
||||||
|
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||||
|
dropdown, but `app_name`, `source` and the Current App sensor
|
||||||
|
display the map value **verbatim** — so `source` can be a string that is not in
|
||||||
|
`source_list`. A blank value falls through to the raw package instead.
|
||||||
|
|
||||||
|
### Remote command script
|
||||||
|
|
||||||
|
`remote.send_command` sends protocol keys: repeats, long presses (`START_LONG`/
|
||||||
|
`END_LONG`) and inter-repeat delays. Some setups need that call to go somewhere else —
|
||||||
|
an IR bridge, a macro, a second device — so one optional **Remote command script**
|
||||||
|
replaces the whole key path when it is set. It receives the service's own parameters,
|
||||||
|
named as they are on the service:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `command` | the list of key commands as submitted, e.g. `["HOME", "KEYCODE_DPAD_CENTER"]` |
|
||||||
|
| `num_repeats` | how many times to send them (default `1`) |
|
||||||
|
| `delay_secs` | the delay between repeats in seconds (default `0.0`) |
|
||||||
|
| `hold_secs` | the requested long-press duration (default `0.0`) — the script owns the key semantics, so it decides what "hold" means |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
script:
|
||||||
|
shield_remote: # select as "Remote command script"
|
||||||
|
alias: Shield remote (IR bridge)
|
||||||
|
sequence:
|
||||||
|
- action: remote.send_command
|
||||||
|
target:
|
||||||
|
entity_id: remote.living_room_bridge
|
||||||
|
data:
|
||||||
|
command: "{{ command }}" # a list: pass it through
|
||||||
|
num_repeats: "{{ num_repeats }}"
|
||||||
|
delay_secs: "{{ delay_secs }}"
|
||||||
|
hold_secs: "{{ hold_secs }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
All four variables are always present, including their defaults, so a script can rely
|
||||||
|
on the shape. Unset — the default — nothing changes: keys go out over the protocol
|
||||||
|
exactly as before.
|
||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
| Entity | Type | Description |
|
| Entity | Type | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||||
| Current App | `sensor` | Currently running app |
|
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
|
||||||
| Now Playing | `sensor` | Formatted now-playing string |
|
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
|
||||||
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ passive until the user pairs via the Pair ADB button.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -21,18 +22,90 @@ from .adb_session import AdbMediaSessionPoller
|
|||||||
from .connection import ShieldConnectionManager
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
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_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ENTRY_VERSION,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
)
|
||||||
|
from .helpers import (
|
||||||
|
entry_host,
|
||||||
|
protocol_cert_paths,
|
||||||
|
resolve_adb_config,
|
||||||
|
seed_adb_enabled,
|
||||||
|
seed_host,
|
||||||
)
|
)
|
||||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||||
|
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
|
||||||
|
|
||||||
|
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
|
||||||
|
interactive SOURCE_IMPORT config flow — nothing is created without the
|
||||||
|
user walking the flow, and pairing stays interactive exactly as for UI
|
||||||
|
setup. Removing the YAML block creates OR deletes nothing. UI-configured
|
||||||
|
entries are never re-seeded (host/MAC duplicate guards).
|
||||||
|
"""
|
||||||
|
domain_config = (config or {}).get(DOMAIN)
|
||||||
|
for block in _yaml_seed_blocks(domain_config or []):
|
||||||
|
if not seed_host(block):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"nvidia_shield YAML block without an address ignored: %s",
|
||||||
|
block.get("name", "<unnamed>"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_IMPORT},
|
||||||
|
data=block,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
|
||||||
|
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
_async_adb_command_service,
|
||||||
|
schema=_ADB_COMMAND_SCHEMA,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _yaml_seed_blocks(domain_config) -> list[dict]:
|
||||||
|
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
|
||||||
|
blocks: list[dict] = []
|
||||||
|
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
|
||||||
|
for block in raw_list:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
name = block.get("name") or DEFAULT_NAME
|
||||||
|
# t32 F4 (review rider): the seed honors the block's shape — a
|
||||||
|
# protocol-only block does NOT force ADB on. t41: the address is ONE
|
||||||
|
# field (`host`, or a legacy protocol_host/adb_host).
|
||||||
|
seed = {
|
||||||
|
"name": name,
|
||||||
|
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||||
|
CONF_HOST: seed_host(block),
|
||||||
|
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||||
|
}
|
||||||
|
if block.get("unique_id"):
|
||||||
|
seed["unique_id"] = str(block["unique_id"])
|
||||||
|
blocks.append(seed)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Poller factory when ADB is configured AND usable today, else None.
|
"""Poller factory when ADB is configured AND usable today, else None.
|
||||||
|
|
||||||
@@ -66,7 +139,18 @@ async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path)
|
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
|
||||||
|
# host/port resolve from entry.options AT CALL TIME, so the poller built
|
||||||
|
# after a zeroconf IP change targets the NEW address without a reload
|
||||||
|
# (the setup-time closure pinned the old address). A key-path change
|
||||||
|
# still needs the Pair ADB button / reconfigure reload, by design.
|
||||||
|
def _factory():
|
||||||
|
adb_now = resolve_adb_config(hass, entry)
|
||||||
|
if adb_now is None:
|
||||||
|
return None
|
||||||
|
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@@ -94,23 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
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 "
|
||||||
@@ -129,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
|
||||||
|
|
||||||
@@ -164,6 +283,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
||||||
"""Handle options update."""
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
@@ -5,19 +5,17 @@ import logging
|
|||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
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.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
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__)
|
||||||
|
|
||||||
@@ -36,6 +34,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = "Pair ADB"
|
_attr_name = "Pair ADB"
|
||||||
|
_attr_entity_category = EntityCategory.CONFIG
|
||||||
_attr_icon = "mdi:link-variant"
|
_attr_icon = "mdi:link-variant"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
@@ -54,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:
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -36,7 +36,13 @@ from .const import (
|
|||||||
ADB_RECONNECT_BACKOFF_CAP,
|
ADB_RECONNECT_BACKOFF_CAP,
|
||||||
ADB_RECONNECT_BACKOFF_START,
|
ADB_RECONNECT_BACKOFF_START,
|
||||||
ADB_RECONNECT_JITTER,
|
ADB_RECONNECT_JITTER,
|
||||||
|
APP_ICON_FALLBACKS,
|
||||||
|
APP_ICON_GENERIC,
|
||||||
|
APP_LABEL_EXIT_MARKER,
|
||||||
APP_LABEL_TTL,
|
APP_LABEL_TTL,
|
||||||
|
APP_LIST_TTL,
|
||||||
|
APP_SWEEP_CONCURRENCY,
|
||||||
|
APP_SWEEP_SPACING,
|
||||||
CONF_LOGCAT_EVENTS,
|
CONF_LOGCAT_EVENTS,
|
||||||
LOGCAT_SUPPRESS_WINDOW,
|
LOGCAT_SUPPRESS_WINDOW,
|
||||||
STATE_STORE_TTL,
|
STATE_STORE_TTL,
|
||||||
@@ -45,6 +51,9 @@ from .const import (
|
|||||||
from .ha_adb_common import (
|
from .ha_adb_common import (
|
||||||
MediaSessionInfo,
|
MediaSessionInfo,
|
||||||
foreground_from_logcat,
|
foreground_from_logcat,
|
||||||
|
icon_for_package,
|
||||||
|
installed_packages,
|
||||||
|
is_launchable,
|
||||||
parse_application_label,
|
parse_application_label,
|
||||||
parse_audio_playback,
|
parse_audio_playback,
|
||||||
)
|
)
|
||||||
@@ -206,7 +215,34 @@ class ShieldConnectionManager:
|
|||||||
# P5-1: device manifest labels — package → (label, stamp). Long-TTL
|
# P5-1: device manifest labels — package → (label, stamp). Long-TTL
|
||||||
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
|
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
|
||||||
# package per watchdog cycle so a burst never blocks the watchdog.
|
# package per watchdog cycle so a burst never blocks the watchdog.
|
||||||
|
# An entry means "a fetch RAN" (t57 UNIT 0): a tuple whose label is
|
||||||
|
# "" is a definitive negative, and a fetch that could not run is not
|
||||||
|
# in here at all — so the flag t55 carried to say so was dead weight
|
||||||
|
# (its only writer always passed True) and is gone.
|
||||||
self._app_labels: dict[str, tuple[str, float]] = {}
|
self._app_labels: dict[str, tuple[str, float]] = {}
|
||||||
|
self._app_label_requests: set[str] = set()
|
||||||
|
# t55: packages whose label fetch is queued or in flight — a render
|
||||||
|
# during the fetch must not enqueue the same package twice.
|
||||||
|
self._app_label_inflight: set[str] = set()
|
||||||
|
# t57: the discovered installed-apps table — (rows, stamp), or None when
|
||||||
|
# no sweep has produced a fresh answer. Only a sweep that RAN writes
|
||||||
|
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
|
||||||
|
# instead, because stamping an empty answer would serve "0 apps" — and
|
||||||
|
# block every retry — for the whole APP_LIST_TTL. Refreshed on demand;
|
||||||
|
# the watchdog cycle never rebuilds it.
|
||||||
|
self._installed_apps: tuple[list[dict], float] | None = None
|
||||||
|
# t62 F1: True when the last sweep could not deliver an answer (no
|
||||||
|
# poller, ADB down, a shell that raised or answered nothing) — so an
|
||||||
|
# empty installed_apps() is "unknown", not "this device has no apps".
|
||||||
|
self._apps_unavailable = False
|
||||||
|
# ...and the clock stamp of an ATTEMPTED failure (shells really went
|
||||||
|
# out). A retry then costs two listing shells, so it is spaced by
|
||||||
|
# APP_SWEEP_RETRY_SECONDS instead of firing on every state poll; an
|
||||||
|
# unavailable transport costs nothing and retries immediately.
|
||||||
|
self._apps_failed_at: float | None = None
|
||||||
|
#: t57: the in-flight sweep, so concurrent callers share one sweep
|
||||||
|
#: instead of stacking a second burst on the same connection.
|
||||||
|
self._apps_sweep: asyncio.Task | None = None
|
||||||
# P5-3 (C-STREAM): the dedicated logcat line-stream consumer.
|
# P5-3 (C-STREAM): the dedicated logcat line-stream consumer.
|
||||||
# NEVER shares the poller's ADB socket — its own device object and
|
# NEVER shares the poller's ADB socket — its own device object and
|
||||||
# connection; CONF_LOGCAT_EVENTS gates it (default off) and the
|
# connection; CONF_LOGCAT_EVENTS gates it (default off) and the
|
||||||
@@ -215,10 +251,12 @@ class ShieldConnectionManager:
|
|||||||
self._logcat_disabled = False
|
self._logcat_disabled = False
|
||||||
self._logcat_failures: list[float] = []
|
self._logcat_failures: list[float] = []
|
||||||
self._logcat_last_pkg: str | None = None
|
self._logcat_last_pkg: str | None = None
|
||||||
|
# G10 (t30): protocol device identity captured once per session —
|
||||||
|
# (name, manufacturer, model, sw) for the registry's device card.
|
||||||
|
self.protocol_device_data: tuple[str, str, str, str] | None = None
|
||||||
self._logcat_last_stamp: float = -1e9
|
self._logcat_last_stamp: float = -1e9
|
||||||
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
|
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
|
||||||
self._logcat_ladder_sleep = asyncio.sleep
|
self._logcat_ladder_sleep = asyncio.sleep
|
||||||
self._app_label_requests: set[str] = set()
|
|
||||||
# Injectable clock keeps the TTL expiry tests deterministic.
|
# Injectable clock keeps the TTL expiry tests deterministic.
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
self._tasks: list[asyncio.Task] = []
|
self._tasks: list[asyncio.Task] = []
|
||||||
@@ -545,12 +583,28 @@ class ShieldConnectionManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def app_label(self, package: str | None) -> str | None:
|
def app_label(self, package: str | None) -> str | None:
|
||||||
"""Cached manifest label for a package (P5-1), or None.
|
"""Cached manifest label for a package (P5-1), or None (t55).
|
||||||
|
|
||||||
A cache miss enqueues an on-demand `pm dump` fetch — drained one
|
A cache miss enqueues an on-demand `pm dump` fetch — drained one
|
||||||
package per watchdog cycle via the manager task pool, so entity
|
package per watchdog cycle via the manager task pool, so entity
|
||||||
refreshes and the watchdog are never blocked by a burst. A failed
|
refreshes and the watchdog are never blocked by a burst.
|
||||||
resolution simply falls through to the next name tier.
|
|
||||||
|
What is cached is the FETCH, not the wishful result (t55 root cause):
|
||||||
|
|
||||||
|
* a fetch that RAN caches its outcome for ``APP_LABEL_TTL`` — a label
|
||||||
|
when the manifest has one, and the empty string otherwise. It is
|
||||||
|
definitive: the device answered, the package simply has no default
|
||||||
|
label (several system/OEM packages do not). No re-enqueue, so a
|
||||||
|
permanently label-less package costs ZERO further round trips
|
||||||
|
(t24 F1 stays closed).
|
||||||
|
* a fetch that could NOT run (no poller, ADB down, shell error) caches
|
||||||
|
nothing, so the next render enqueues again — a miss is retried
|
||||||
|
visibly instead of being stamped as an eternal "no label". This was
|
||||||
|
the live defect: the old code stored ("", stamp) on every failure
|
||||||
|
path, which silenced the label tier for the whole 24 h TTL and left
|
||||||
|
app_name showing the raw package id.
|
||||||
|
* while a fetch for the package is queued or in flight, the next
|
||||||
|
render does not enqueue it a second time.
|
||||||
"""
|
"""
|
||||||
if not package:
|
if not package:
|
||||||
return None
|
return None
|
||||||
@@ -558,31 +612,149 @@ class ShieldConnectionManager:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
label, stamp = cached
|
label, stamp = cached
|
||||||
if self._clock() - stamp <= APP_LABEL_TTL:
|
if self._clock() - stamp <= APP_LABEL_TTL:
|
||||||
# empty label = negative cache (t24 F1): the pm dump ran and
|
# empty label = negative cache (t24 F1): the pm dump RAN and
|
||||||
# found nothing — do not re-enqueue, zero per-heartbeat tax
|
# found nothing — do not re-enqueue, zero per-heartbeat tax.
|
||||||
|
# A fetch that did not run is not cached at all (t57 UNIT 0),
|
||||||
|
# so there is no "unresolved" shape to fall through on.
|
||||||
return label or None
|
return label or None
|
||||||
self._app_labels.pop(package, None) # stale — refetch on demand
|
self._app_labels.pop(package, None) # stale — refetch on demand
|
||||||
|
if package not in self._app_label_requests and (
|
||||||
|
package not in self._app_label_inflight
|
||||||
|
):
|
||||||
self._app_label_requests.add(package)
|
self._app_label_requests.add(package)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fetch_app_label(self, package: str) -> None:
|
def _known_label(self, package: str) -> tuple[bool, str]:
|
||||||
"""One `pm dump` label fetch; failure falls through silently."""
|
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
|
||||||
|
|
||||||
|
The accessor the DISCOVERY sweep needs and ``app_label`` deliberately is
|
||||||
|
not: ``app_label`` answers "what should be displayed", so a definitive
|
||||||
|
negative (a package with no manifest label) comes back as ``None``,
|
||||||
|
exactly like a miss. A sweep that used it would re-dump every label-less
|
||||||
|
package on every run and throw away the 24 h cache it is supposed to
|
||||||
|
benefit from. Here the two are distinguishable.
|
||||||
|
"""
|
||||||
|
cached = self._app_labels.get(package)
|
||||||
|
if cached is None:
|
||||||
|
return False, ""
|
||||||
|
label, stamp = cached
|
||||||
|
if self._clock() - stamp > APP_LABEL_TTL:
|
||||||
|
self._app_labels.pop(package, None) # stale — refetch on demand
|
||||||
|
return False, ""
|
||||||
|
return True, label
|
||||||
|
|
||||||
|
def cache_app_label(self, package: str, label: str | None) -> None:
|
||||||
|
"""Record a label fetch that RAN, and what it found (t55, t57).
|
||||||
|
|
||||||
|
The cache's only writer. ``None``/"" is the negative result devices
|
||||||
|
genuinely report for label-less packages, and it is definitive: no
|
||||||
|
further ``pm dump`` for ``APP_LABEL_TTL``. A fetch that did NOT run
|
||||||
|
must not call this — that was the live defect (see
|
||||||
|
``_fetch_app_label``).
|
||||||
|
"""
|
||||||
|
self._app_labels[package] = (label or "", self._clock())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_label_requests(self) -> set[str]:
|
||||||
|
"""Packages queued for a manifest-label fetch (public, for tests)."""
|
||||||
|
return set(self._app_label_requests)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_label_inflight(self) -> set[str]:
|
||||||
|
"""Packages whose label fetch is running right now (public, tests)."""
|
||||||
|
return set(self._app_label_inflight)
|
||||||
|
|
||||||
|
async def async_capture_protocol_device_data(self) -> None:
|
||||||
|
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
|
||||||
|
if self._protocol is None or self.protocol_device_data is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
info = self._protocol.device_info or {} # {manufacturer, model, sw_version}
|
||||||
|
name, _mac = await asyncio.wait_for(
|
||||||
|
self._protocol.async_get_name_and_mac(), timeout=10.0
|
||||||
|
)
|
||||||
|
self.protocol_device_data = (
|
||||||
|
name,
|
||||||
|
info.get("manufacturer", "Google"),
|
||||||
|
info.get("model", "Shield TV"),
|
||||||
|
info.get("sw_version", ""),
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
_LOGGER.debug("protocol device info unavailable: %s", err)
|
||||||
|
|
||||||
|
async def _dump_label(self, package: str) -> str | None:
|
||||||
|
"""One `pm dump` label read: the label, or None for a FAILED fetch.
|
||||||
|
|
||||||
|
The ONE place the dump is issued, shared by the on-demand path
|
||||||
|
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
|
||||||
|
distinction — "the command ran and found no label" versus "the
|
||||||
|
transport delivered nothing" — cannot drift between them. A successful
|
||||||
|
fetch is also cached here, so both paths benefit from the 24 h cache and
|
||||||
|
neither double-stamps.
|
||||||
|
|
||||||
|
Returns the label (possibly "") when the dump RAN, and None when it did
|
||||||
|
not run or delivered no output at all.
|
||||||
|
|
||||||
|
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
|
||||||
|
after a ``head`` pipeline, and the Android shell that runs this has no
|
||||||
|
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
|
||||||
|
reads its VALUE — ``parse_application_label`` strips the marker line — and
|
||||||
|
the only property tested here is that a command which RAN leaves non-blank
|
||||||
|
output even when the device prints no label. That non-blankness is what
|
||||||
|
separates "ran, no label" from "the transport delivered nothing", which
|
||||||
|
this method turns into a cached negative versus a retry.
|
||||||
|
"""
|
||||||
poller = self._poller
|
poller = self._poller
|
||||||
if poller is None or not self.connected:
|
if poller is None or not self.connected:
|
||||||
return
|
_LOGGER.debug(
|
||||||
|
"app label for %s not fetched (ADB not connected) — will "
|
||||||
|
"retry on the next render",
|
||||||
|
package,
|
||||||
|
)
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
dump = await poller.shell(
|
dump = await poller.shell(
|
||||||
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
|
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
|
||||||
|
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||||
)
|
)
|
||||||
label = parse_application_label(dump or "")
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
_LOGGER.debug("app label fetch failed for %s: %s", package, err)
|
_LOGGER.debug(
|
||||||
return
|
"app label fetch failed for %s (%s) — will retry on the "
|
||||||
|
"next render",
|
||||||
|
package,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if dump is None or not dump.strip():
|
||||||
|
# the transport never delivered output: a FAILED fetch, not an
|
||||||
|
# answer. Caching here is the t55 defect this repairs.
|
||||||
|
_LOGGER.debug(
|
||||||
|
"app label fetch for %s returned no output — will retry on "
|
||||||
|
"the next render",
|
||||||
|
package,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
label = parse_application_label(dump)
|
||||||
# F1: label-less pm dumps negatively cache too (empty label) so a
|
# F1: label-less pm dumps negatively cache too (empty label) so a
|
||||||
# later cycle performs zero additional pm dumps for the package
|
# later cycle performs zero additional pm dumps for the package.
|
||||||
self._app_labels[package] = (label or "", self._clock())
|
self.cache_app_label(package, label)
|
||||||
|
return label
|
||||||
|
|
||||||
|
async def _fetch_app_label(self, package: str) -> None:
|
||||||
|
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
|
||||||
|
|
||||||
|
The on-demand half of the label path (one package per watchdog cycle,
|
||||||
|
driven by an entity render). The dump itself — including the t57 UNIT 0
|
||||||
|
distinction between a FAILED fetch and a genuinely label-less package —
|
||||||
|
lives in :meth:`_dump_label`, so the sweep and this path cannot drift.
|
||||||
|
"""
|
||||||
|
self._app_label_inflight.add(package)
|
||||||
|
try:
|
||||||
|
await self._dump_label(package)
|
||||||
|
finally:
|
||||||
|
self._app_label_inflight.discard(package)
|
||||||
|
|
||||||
def _on_logcat_app_start(self, package: str) -> None:
|
def _on_logcat_app_start(self, package: str) -> None:
|
||||||
"""Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3).
|
"""Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3).
|
||||||
@@ -735,6 +907,228 @@ class ShieldConnectionManager:
|
|||||||
self._logcat_task.cancel()
|
self._logcat_task.cancel()
|
||||||
self._logcat_task = None
|
self._logcat_task = None
|
||||||
|
|
||||||
|
# ── t57: installed-app discovery ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# MARK — documented, not observed (t62 nit c): the output SHAPES the two
|
||||||
|
# listing commands rely on (`pm list packages -3` → `package:<id>` lines,
|
||||||
|
# `cmd package resolve-activity --brief -c …HOME` → `package/activity`) are
|
||||||
|
# Android's documentation, not a captured run: no real Shield was available
|
||||||
|
# when discovery was written (t57). Every fixture in
|
||||||
|
# tests/test_installed_apps.py is built from those shapes and the parsers in
|
||||||
|
# ``ha_adb_common.installed_apps`` are deliberately tolerant of whitespace
|
||||||
|
# and ordering. Same mark as the state=6 buffering inference in
|
||||||
|
# ``media_player.py``.
|
||||||
|
|
||||||
|
#: t62 F1: how long after an ATTEMPTED sweep failed before another one is
|
||||||
|
#: issued. A retry costs two listing shells, so it waits out one state poll
|
||||||
|
#: (30 s) rather than hammering a silent socket; ``force=True`` bypasses it.
|
||||||
|
#: An unavailable transport (no poller, ADB down) costs nothing and is
|
||||||
|
#: retried on the very next update.
|
||||||
|
APP_SWEEP_RETRY_SECONDS: float = 30.0
|
||||||
|
|
||||||
|
def installed_apps(self) -> list[dict]:
|
||||||
|
"""The discovered apps, ``[{package, name, icon}]``, cached (t57).
|
||||||
|
|
||||||
|
Returns the cached table whenever it is fresh, and an EMPTY list when
|
||||||
|
there is no fresh answer: ADB disabled, not connected, the first sweep
|
||||||
|
still running, or a sweep that could not run (t62 F1). Empty is
|
||||||
|
therefore "nothing to show", never by itself "this device has no apps"
|
||||||
|
— ask :meth:`installed_apps_status` which of the two it is. A caller
|
||||||
|
that wants the table filled calls
|
||||||
|
:meth:`async_refresh_installed_apps`; this accessor never blocks and
|
||||||
|
never issues a shell itself, so a state poll stays free of discovery
|
||||||
|
work.
|
||||||
|
"""
|
||||||
|
if self._installed_apps is None:
|
||||||
|
return []
|
||||||
|
rows, stamp = self._installed_apps
|
||||||
|
if self._clock() - stamp > APP_LIST_TTL:
|
||||||
|
return [] # stale — a refresh repopulates it, the poll does not
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed_apps_stale(self) -> bool:
|
||||||
|
"""True when no fresh table is available (including "never swept")."""
|
||||||
|
if self._installed_apps is None:
|
||||||
|
return True
|
||||||
|
_rows, stamp = self._installed_apps
|
||||||
|
return self._clock() - stamp > APP_LIST_TTL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed_apps_status(self) -> str:
|
||||||
|
"""Why :meth:`installed_apps` may be empty, in one word (t62 F1).
|
||||||
|
|
||||||
|
* ``"ok"`` — a sweep RAN and its answer is fresh. An empty table with
|
||||||
|
this status is a real answer (the device reported no launchable
|
||||||
|
apps), which is the case the old stamped-empty cache could not say.
|
||||||
|
* ``"unknown"`` — the last sweep could not deliver an answer (no
|
||||||
|
poller, ADB down, a shell that raised or answered nothing). Nothing
|
||||||
|
is known about the device, and the next update retries.
|
||||||
|
* ``"pending"`` — no sweep has completed yet, or the last answer
|
||||||
|
expired and a refresh is due.
|
||||||
|
"""
|
||||||
|
if not self.installed_apps_stale:
|
||||||
|
return "ok"
|
||||||
|
return "unknown" if self._apps_unavailable else "pending"
|
||||||
|
|
||||||
|
async def async_refresh_installed_apps(self, force: bool = False) -> list[dict]:
|
||||||
|
"""(Re)build the installed-app table over ADB, bounded and shared (t57).
|
||||||
|
|
||||||
|
One sweep at a time: concurrent callers await the same task rather than
|
||||||
|
starting a second burst, and a fresh table is returned as-is unless
|
||||||
|
``force``. The sweep is deliberately NOT part of the watchdog: it is a
|
||||||
|
handful of shells on demand, not a per-cycle cost.
|
||||||
|
|
||||||
|
Failure is not an answer (t62 F1). No poller, a disconnected transport,
|
||||||
|
a shell that raises, or a sweep that delivered no output records
|
||||||
|
``unknown`` and stores NO table, so the next non-forced call — an
|
||||||
|
entity poll or the standard ``update_entity`` action — retries instead
|
||||||
|
of serving a stamped empty answer for the whole APP_LIST_TTL. A sweep
|
||||||
|
that reached the device and failed is retried at most once per
|
||||||
|
``APP_SWEEP_RETRY_SECONDS`` so a silent socket is not hammered; a sweep
|
||||||
|
that RAN stores its answer, including a genuinely empty list.
|
||||||
|
"""
|
||||||
|
if not force and not self.installed_apps_stale:
|
||||||
|
return self.installed_apps()
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and self._apps_failed_at is not None
|
||||||
|
and self._clock() - self._apps_failed_at < self.APP_SWEEP_RETRY_SECONDS
|
||||||
|
):
|
||||||
|
# The previous attempt really issued shells and failed: space the
|
||||||
|
# retries by one poll interval instead of re-issuing them every
|
||||||
|
# 30 s on a socket that is not answering (t62 F1).
|
||||||
|
return self.installed_apps()
|
||||||
|
if self._apps_sweep is not None and not self._apps_sweep.done():
|
||||||
|
await asyncio.shield(self._apps_sweep)
|
||||||
|
return self.installed_apps()
|
||||||
|
task = self._create_task(self._sweep_installed_apps(), "apps_sweep")
|
||||||
|
self._apps_sweep = task
|
||||||
|
try:
|
||||||
|
rows = await task
|
||||||
|
finally:
|
||||||
|
if self._apps_sweep is task:
|
||||||
|
self._apps_sweep = None
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
async def _sweep_installed_apps(self) -> list[dict]:
|
||||||
|
"""The sweep itself: list, label, icon — then stamp the cache (t57).
|
||||||
|
|
||||||
|
Only a sweep that RAN stamps the cache (t62 F1): every failure path
|
||||||
|
records ``unknown`` and leaves ``_installed_apps`` alone, so the table
|
||||||
|
the entity serves is the last one that was real — never a fabricated
|
||||||
|
empty answer that also blocks the retry for APP_LIST_TTL.
|
||||||
|
"""
|
||||||
|
poller = self._poller
|
||||||
|
if poller is None or not self.connected:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"installed-app discovery skipped (ADB unavailable) — the "
|
||||||
|
"options page and the app list stay on what is already known"
|
||||||
|
)
|
||||||
|
self._note_apps_unavailable(attempted=False)
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
user_output = await poller.shell("pm list packages -3")
|
||||||
|
launcher_output = await poller.shell(
|
||||||
|
"cmd package resolve-activity --brief -c "
|
||||||
|
"android.intent.category.HOME"
|
||||||
|
)
|
||||||
|
except Exception as err: # pragma: no cover - shell swallows its own
|
||||||
|
_LOGGER.debug("installed-app discovery failed: %s", err)
|
||||||
|
self._note_apps_unavailable(attempted=True)
|
||||||
|
return []
|
||||||
|
if user_output is None and launcher_output is None:
|
||||||
|
# the transport delivered nothing at all: no list is NOT an answer
|
||||||
|
_LOGGER.debug(
|
||||||
|
"installed-app discovery got no output — will retry on the "
|
||||||
|
"next update"
|
||||||
|
)
|
||||||
|
self._note_apps_unavailable(attempted=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
packages = [p for p in installed_packages(user_output, launcher_output)
|
||||||
|
if is_launchable(p)]
|
||||||
|
labels = await self._sweep_labels(packages)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"package": package,
|
||||||
|
"name": labels.get(package) or package,
|
||||||
|
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
|
||||||
|
}
|
||||||
|
for package in packages
|
||||||
|
]
|
||||||
|
self._installed_apps = (rows, self._clock())
|
||||||
|
self._apps_unavailable = False
|
||||||
|
self._apps_failed_at = None
|
||||||
|
_LOGGER.debug("installed-app discovery found %d apps", len(rows))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _note_apps_unavailable(self, *, attempted: bool) -> None:
|
||||||
|
"""Record a sweep that could not deliver an answer (t62 F1).
|
||||||
|
|
||||||
|
``attempted`` says whether shells were actually issued: only then does
|
||||||
|
the retry window apply, because only then does a retry cost anything.
|
||||||
|
"""
|
||||||
|
self._apps_unavailable = True
|
||||||
|
self._apps_failed_at = self._clock() if attempted else None
|
||||||
|
|
||||||
|
async def _sweep_labels(self, packages: list[str]) -> dict[str, str]:
|
||||||
|
"""Labels for a whole sweep, in a bounded burst (t57).
|
||||||
|
|
||||||
|
Deliberately NOT through ``_app_label_requests``: that queue is drained
|
||||||
|
ONE package per watchdog cycle, which is right for the live entity path
|
||||||
|
(a render must never fan out into shells) but would take a couple of
|
||||||
|
minutes for a Shield with ~40 apps. Here the fetches run
|
||||||
|
``APP_SWEEP_CONCURRENCY`` at a time with a short spacing, so a typical
|
||||||
|
Shield finishes in seconds while the socket still sees a trickle rather
|
||||||
|
than a flood.
|
||||||
|
|
||||||
|
Cached labels are reused, so a second sweep costs almost nothing; a
|
||||||
|
failed fetch is NOT cached (t57 UNIT 0) and the app falls back to its
|
||||||
|
package id for this table while the next sweep retries it.
|
||||||
|
|
||||||
|
The check goes through :meth:`_known_label` rather than
|
||||||
|
:meth:`app_label`, so a package the device has definitively answered
|
||||||
|
"no label" for is not re-dumped on every sweep.
|
||||||
|
"""
|
||||||
|
cached: dict[str, str] = {}
|
||||||
|
missing: list[str] = []
|
||||||
|
for package in packages:
|
||||||
|
known, label = self._known_label(package)
|
||||||
|
if not known:
|
||||||
|
missing.append(package)
|
||||||
|
elif label:
|
||||||
|
cached[package] = label
|
||||||
|
# a known NEGATIVE needs no entry: the row falls back to its
|
||||||
|
# package id, and the cache already holds the definitive answer
|
||||||
|
if not missing:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(APP_SWEEP_CONCURRENCY)
|
||||||
|
|
||||||
|
async def _one(package: str) -> tuple[str, str | None]:
|
||||||
|
async with semaphore:
|
||||||
|
# a small spacing keeps the burst polite on a single socket
|
||||||
|
await asyncio.sleep(APP_SWEEP_SPACING)
|
||||||
|
return package, await self._dump_label(package)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_one(package) for package in missing), return_exceptions=True
|
||||||
|
)
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, BaseException):
|
||||||
|
continue # one bad package never fails the sweep
|
||||||
|
package, label = result
|
||||||
|
if label:
|
||||||
|
cached[package] = label
|
||||||
|
else:
|
||||||
|
# None here means the fetch FAILED (nothing cached); the empty
|
||||||
|
# string means the device answered with no label. Both leave
|
||||||
|
# the row on its package id, and both keep the app in the list.
|
||||||
|
cached[package] = ""
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
def _drain_app_label_requests(self) -> None:
|
def _drain_app_label_requests(self) -> None:
|
||||||
"""Start at most one label fetch per watchdog cycle."""
|
"""Start at most one label fetch per watchdog cycle."""
|
||||||
if not self._app_label_requests or self._poller is None:
|
if not self._app_label_requests or self._poller is None:
|
||||||
@@ -871,6 +1265,7 @@ class ShieldConnectionManager:
|
|||||||
self._last_known.clear()
|
self._last_known.clear()
|
||||||
self._app_labels.clear()
|
self._app_labels.clear()
|
||||||
self._app_label_requests.clear()
|
self._app_label_requests.clear()
|
||||||
|
self._app_label_inflight.clear()
|
||||||
if self._poller is not None:
|
if self._poller is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self._poller.disconnect()
|
await self._poller.disconnect()
|
||||||
@@ -997,6 +1392,14 @@ class ShieldConnectionManager:
|
|||||||
foreground = await poller.poll_foreground()
|
foreground = await poller.poll_foreground()
|
||||||
if foreground:
|
if foreground:
|
||||||
self.store.set(FIELD_FOREGROUND, foreground)
|
self.store.set(FIELD_FOREGROUND, foreground)
|
||||||
|
# G10: capture the protocol device identity for the registry card
|
||||||
|
if self._protocol is not None:
|
||||||
|
self._tasks.append(
|
||||||
|
self._create_task(
|
||||||
|
self.async_capture_protocol_device_data(),
|
||||||
|
"protocol_device_capture",
|
||||||
|
)
|
||||||
|
)
|
||||||
# P5-1: manifest labels resolve on demand, one package per cycle
|
# P5-1: manifest labels resolve on demand, one package per cycle
|
||||||
self._drain_app_label_requests()
|
self._drain_app_label_requests()
|
||||||
# P5-3: ensure the (optional, default-off) logcat fast-path runs
|
# P5-3: ensure the (optional, default-off) logcat fast-path runs
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -104,6 +121,13 @@ CONF_APP_ICON_MAP = "app_icon_map"
|
|||||||
# updates, not per poll).
|
# updates, not per poll).
|
||||||
APP_LABEL_TTL = 24 * 3600
|
APP_LABEL_TTL = 24 * 3600
|
||||||
|
|
||||||
|
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
|
||||||
|
# told from a package that genuinely has no label. ``adb shell`` output ending
|
||||||
|
# in this marker means the command ran; an empty answer still carries it, and
|
||||||
|
# a transport that never delivered output does not. The value is arbitrary but
|
||||||
|
# unlikely to collide with a manifest line.
|
||||||
|
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
|
||||||
|
|
||||||
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||||
# (same security posture as the core androidtv integration).
|
# (same security posture as the core androidtv integration).
|
||||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||||
@@ -127,6 +151,45 @@ DEFAULT_APP_DIRECTORY = {
|
|||||||
"com.google.android.youtube.tv": "YouTube",
|
"com.google.android.youtube.tv": "YouTube",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
|
||||||
|
# app's icon resource (an extracted APK's icon is a bitmap only its own code
|
||||||
|
# can resolve, and `dumpsys package` prints no drawable reference), so a device
|
||||||
|
# icon is not obtainable over plain shell. These MDI names are therefore a
|
||||||
|
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
|
||||||
|
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
|
||||||
|
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
|
||||||
|
APP_ICON_FALLBACKS = {
|
||||||
|
"ar.tvplayer.tv": "mdi:television-classic",
|
||||||
|
"com.github.damontecres.wholphin": "mdi:jellyfish",
|
||||||
|
"com.netflix.ninja": "mdi:netflix",
|
||||||
|
"com.google.android.youtube.tv": "mdi:youtube-tv",
|
||||||
|
"com.google.android.tvlauncher": "mdi:view-dashboard",
|
||||||
|
"org.xbmc.kodi": "mdi:kodi",
|
||||||
|
"com.plexapp.android": "mdi:plex",
|
||||||
|
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
|
||||||
|
"com.disney.disneyplus": "mdi:movie-open",
|
||||||
|
"com.spotify.tv.android": "mdi:spotify",
|
||||||
|
"com.google.android.apps.youtube.music": "mdi:music",
|
||||||
|
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
|
||||||
|
"com.spotify.music": "mdi:spotify",
|
||||||
|
}
|
||||||
|
#: The fallback for an app this map has never heard of.
|
||||||
|
APP_ICON_GENERIC = "mdi:television"
|
||||||
|
|
||||||
|
# t57: installed-app discovery. The package list changes only when the user
|
||||||
|
# installs or uninstalls something, so a long TTL is right; refreshing is
|
||||||
|
# explicit (the options page and the app-list sensor ask for it) rather than a
|
||||||
|
# per-poll cost.
|
||||||
|
APP_LIST_TTL = 6 * 3600
|
||||||
|
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
|
||||||
|
#: typical Shield quickly, few enough that the label fetches never queue
|
||||||
|
#: behind each other on one socket or stall the watchdog.
|
||||||
|
APP_SWEEP_CONCURRENCY = 4
|
||||||
|
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
|
||||||
|
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
|
||||||
|
#: a few seconds instead of a few minutes.
|
||||||
|
APP_SWEEP_SPACING = 0.05
|
||||||
|
|
||||||
CHANNEL_DIGIT_DELAY = 0.1
|
CHANNEL_DIGIT_DELAY = 0.1
|
||||||
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
||||||
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
||||||
@@ -143,3 +206,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,14 +16,31 @@ 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**"
|
||||||
|
|
||||||
|
# G14: the documented explicit redaction field list — the audit calls out
|
||||||
|
# config fields like the hosts; pairing/key-shaped fields are masked by
|
||||||
|
# async_redact_data and the marker scan adds a second layer.
|
||||||
|
_TO_REDACT = {
|
||||||
|
"pairing_code",
|
||||||
|
"adb_pairing_code",
|
||||||
|
"protocol_pairing_code",
|
||||||
|
"serial_number",
|
||||||
|
"uuid",
|
||||||
|
"adb_host",
|
||||||
|
"protocol_host",
|
||||||
|
"host",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _redact(obj):
|
def _redact(obj):
|
||||||
|
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
||||||
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {
|
return {
|
||||||
@@ -65,14 +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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return _redact(payload)
|
# G14 (t30) + t37 D2: the documented helper is the primary redaction path
|
||||||
|
# with an explicit field list (the hosts included), and the marker scan
|
||||||
|
# above runs defensively after it. It is imported at MODULE scope from
|
||||||
|
# homeassistant.helpers.redact — the real module; the old
|
||||||
|
# `homeassistant.util.diagnostic` never existed, and the swallowed
|
||||||
|
# ImportError turned the whole pass into a silent no-op.
|
||||||
|
return _redact(async_redact_data(payload, _TO_REDACT))
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["async_get_config_entry_diagnostics"]
|
__all__ = ["async_get_config_entry_diagnostics"]
|
||||||
@@ -11,6 +11,13 @@ from .const import (
|
|||||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
)
|
)
|
||||||
from .foreground import parse_foreground
|
from .foreground import parse_foreground
|
||||||
|
from .installed_apps import (
|
||||||
|
icon_for_package,
|
||||||
|
installed_packages,
|
||||||
|
is_launchable,
|
||||||
|
parse_active_launcher,
|
||||||
|
parse_package_list,
|
||||||
|
)
|
||||||
from .intent import PlaybackIntent
|
from .intent import PlaybackIntent
|
||||||
from .keys import ensure_adb_key
|
from .keys import ensure_adb_key
|
||||||
from .logcat_events import foreground_from_logcat
|
from .logcat_events import foreground_from_logcat
|
||||||
@@ -38,6 +45,11 @@ __all__ = [
|
|||||||
"parse_audio_playback",
|
"parse_audio_playback",
|
||||||
"parse_foreground",
|
"parse_foreground",
|
||||||
"foreground_from_logcat",
|
"foreground_from_logcat",
|
||||||
|
"icon_for_package",
|
||||||
|
"installed_packages",
|
||||||
|
"is_launchable",
|
||||||
|
"parse_active_launcher",
|
||||||
|
"parse_package_list",
|
||||||
"parse_wakefulness",
|
"parse_wakefulness",
|
||||||
"parse_media_session",
|
"parse_media_session",
|
||||||
"running_apps_from_sessions",
|
"running_apps_from_sessions",
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Discover the apps actually installed on the Shield (t57).
|
||||||
|
|
||||||
|
The live complaint ("it doesn't find all the apps") came from the curated
|
||||||
|
fallback list: only four packages were ever offered, so a user with Wholphin,
|
||||||
|
SmartTube, Kodi, Plex or anything else saw nothing. The device knows its own
|
||||||
|
apps, so this module asks it.
|
||||||
|
|
||||||
|
The commands (both plain `pm`, available on Shield Experience 9 / Android 11
|
||||||
|
with no extra permission beyond the ADB pairing this integration already has):
|
||||||
|
|
||||||
|
* ``pm list packages -3`` — third-party (user-installed) packages, one
|
||||||
|
``package:<id>`` line each. This is what the live complaint is about: an app
|
||||||
|
the user installed is a ``-3`` package.
|
||||||
|
* ``cmd package resolve-activity --brief -c android.intent.category.HOME``
|
||||||
|
— the ACTIVE launcher (``com.google.android.tvlauncher/....MainActivity``).
|
||||||
|
A launcher is a system app, so it is not in ``-3``, yet it is exactly the app
|
||||||
|
a user sees and may want to name or launch — so it is added explicitly.
|
||||||
|
|
||||||
|
System apps are otherwise NOT enumerated: the Shield ships a few hundred of
|
||||||
|
them and they are not launchable apps a media-player source list wants. The
|
||||||
|
one system app that matters (the launcher) is resolved, not filtered.
|
||||||
|
|
||||||
|
Labels come from the existing ``pm dump`` + ``parse_application_label`` path and
|
||||||
|
its 24 h cache (``ShieldConnectionManager.app_label``), so discovery adds no
|
||||||
|
second naming mechanism.
|
||||||
|
|
||||||
|
Icons are best effort and explicitly heuristic — see ``icon_for_package``.
|
||||||
|
|
||||||
|
MARK — documented, not observed (t62 nit c): the output SHAPES this module parses
|
||||||
|
— ``pm list packages -3`` printing ``package:<id>`` lines, and
|
||||||
|
``resolve-activity --brief`` printing ``package/activity`` — are Android's
|
||||||
|
documentation, not a captured run. No real Shield was available when discovery was
|
||||||
|
written (t57), and no fixture in this repo is a device capture of either command
|
||||||
|
(``tests/fixtures`` holds activity, wakefulness and media-session dumps instead), so
|
||||||
|
everything below is exercised from the hand-built fixtures in
|
||||||
|
``tests/test_installed_apps.py``. That is why the parsers are deliberately tolerant:
|
||||||
|
an output shape we have never seen degrades to an empty list rather than to a wrong
|
||||||
|
one. Same mark as the state=6 buffering inference — the dataclass note in
|
||||||
|
``ha_adb_common/media_session.py``, echoed in ``media_player.py`` — and as the
|
||||||
|
discovery section of ``connection.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: One `pm list packages` line: ``package:com.example.app``.
|
||||||
|
_PACKAGE_LINE_RE = re.compile(r"^package:(?P<package>[\w.]+)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
#: `resolve-activity --brief` prints ``package/activity`` on the last line,
|
||||||
|
#: possibly preceded by the (verbose) intent header.
|
||||||
|
_COMPONENT_RE = re.compile(r"^(?P<package>[\w.]+)/[\w.$]+$", re.MULTILINE)
|
||||||
|
|
||||||
|
#: Activity components that belong to a launcher but are not what the user
|
||||||
|
#: opens: settings/hidden entry points a HOME resolution can land on.
|
||||||
|
_LAUNCHER_NOISE = ("com.android.tv.settings",)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_package_list(output: str | None) -> list[str]:
|
||||||
|
"""The package ids in a ``pm list packages`` dump, in device order.
|
||||||
|
|
||||||
|
Tolerant on purpose: the documented output (see the module MARK — the shape is
|
||||||
|
Android's documentation, not a capture) has one ``package:<id>`` per line, and a
|
||||||
|
shell that prepends noise (adb banners, warnings) must not lose the list.
|
||||||
|
Duplicates are collapsed, order preserved.
|
||||||
|
"""
|
||||||
|
if not output:
|
||||||
|
return []
|
||||||
|
seen: set[str] = set()
|
||||||
|
packages: list[str] = []
|
||||||
|
for match in _PACKAGE_LINE_RE.finditer(output):
|
||||||
|
package = match.group("package")
|
||||||
|
if package not in seen:
|
||||||
|
seen.add(package)
|
||||||
|
packages.append(package)
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def parse_active_launcher(output: str | None) -> str | None:
|
||||||
|
"""The active HOME package from ``cmd package resolve-activity --brief``.
|
||||||
|
|
||||||
|
Android prints a verbose intent header and then a single
|
||||||
|
``package/activity`` line; only the last component-looking line is the
|
||||||
|
answer. Returns None when nothing resolved (a device with no launcher, or a
|
||||||
|
command that produced no output) — the caller treats that as "no launcher
|
||||||
|
to add" rather than as a failure.
|
||||||
|
"""
|
||||||
|
if not output:
|
||||||
|
return None
|
||||||
|
packages = _COMPONENT_RE.findall(output)
|
||||||
|
for package in reversed(packages):
|
||||||
|
if package not in _LAUNCHER_NOISE:
|
||||||
|
return package
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def installed_packages(user_output: str | None, launcher_output: str | None) -> list[str]:
|
||||||
|
"""The discovered package ids: user apps first, then the active launcher.
|
||||||
|
|
||||||
|
The launcher goes last so a source list built from this keeps the user's own
|
||||||
|
apps at the top, and it is de-duplicated because a launcher CAN be a
|
||||||
|
third-party package (a replacement TV launcher installed from the store).
|
||||||
|
"""
|
||||||
|
packages = parse_package_list(user_output)
|
||||||
|
launcher = parse_active_launcher(launcher_output)
|
||||||
|
if launcher and launcher not in packages:
|
||||||
|
packages.append(launcher)
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def icon_for_package(package: str, curated: dict[str, str] | None = None,
|
||||||
|
generic: str = "mdi:television") -> str:
|
||||||
|
"""A best-effort MDI icon for a package, never a non-string (t57).
|
||||||
|
|
||||||
|
HEURISTIC, and labelled as such: the icon is looked up in a curated
|
||||||
|
package -> MDI map, and anything unknown gets the generic fallback. There is
|
||||||
|
no device query behind this because there cannot be one — ``pm dump`` does
|
||||||
|
not print the app's icon resource, and resolving an extracted APK's bitmap
|
||||||
|
would need code the app itself runs. The user's own icon column (a picture
|
||||||
|
URL or a ``camera.*`` entity) is what ``entity_picture`` consumes, and it
|
||||||
|
outranks this.
|
||||||
|
|
||||||
|
Returns a string in every case: the UI is never handed None or a dict.
|
||||||
|
"""
|
||||||
|
if not package:
|
||||||
|
return generic
|
||||||
|
table = curated if curated is not None else {}
|
||||||
|
return table.get(package) or generic
|
||||||
|
|
||||||
|
|
||||||
|
def is_launchable(package: str) -> bool:
|
||||||
|
"""Cheap sanity filter for a package id.
|
||||||
|
|
||||||
|
Not a real Android query: a package id is dotted and non-empty. The filter
|
||||||
|
exists so a malformed line cannot become a table row or a source entry.
|
||||||
|
"""
|
||||||
|
return bool(package) and "." in package and all(
|
||||||
|
part.isalnum() or part in ("_", "-") for part in package.split(".")
|
||||||
|
)
|
||||||
@@ -17,6 +17,14 @@ class MediaSessionInfo:
|
|||||||
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
|
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
|
||||||
wallclock value. Use :meth:`position_at_uptime` with the device's current
|
wallclock value. Use :meth:`position_at_uptime` with the device's current
|
||||||
uptime to extrapolate the live position.
|
uptime to extrapolate the live position.
|
||||||
|
|
||||||
|
``playing``/``paused``/``buffering`` are independent flags mapped from the
|
||||||
|
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
|
||||||
|
(t55). 6 is the stock-Android buffering value and the mapping is INFERRED
|
||||||
|
from the ``state=PlaybackState {state=N, ...}`` line shape, not from a
|
||||||
|
captured Shield dump: no fixture in this repo contains state=6, and no
|
||||||
|
real device was available. Nothing here is vendor-specific either way —
|
||||||
|
every integer is read straight out of the dump.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@@ -25,6 +33,7 @@ class MediaSessionInfo:
|
|||||||
package: str | None = None
|
package: str | None = None
|
||||||
playing: bool = False
|
playing: bool = False
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
buffering: bool = False
|
||||||
position: float | None = None
|
position: float | None = None
|
||||||
speed: float | None = None
|
speed: float | None = None
|
||||||
updated: int | None = None
|
updated: int | None = None
|
||||||
@@ -81,7 +90,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
"""Parse `dumpsys media_session` output for ALL active media sessions.
|
"""Parse `dumpsys media_session` output for ALL active media sessions.
|
||||||
|
|
||||||
Returns a list of every active session that has metadata or a known
|
Returns a list of every active session that has metadata or a known
|
||||||
playback state, sorted: playing first, then paused, then others.
|
playback state, sorted: playing first, then buffering, then paused, then
|
||||||
|
others (t55).
|
||||||
"""
|
"""
|
||||||
if not dump:
|
if not dump:
|
||||||
return []
|
return []
|
||||||
@@ -89,7 +99,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
sessions: list[MediaSessionInfo] = []
|
sessions: list[MediaSessionInfo] = []
|
||||||
current_pkg: str | None = None
|
current_pkg: str | None = None
|
||||||
in_active = False
|
in_active = False
|
||||||
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
# Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering
|
||||||
|
current_state = 0
|
||||||
current_position: float | None = None
|
current_position: float | None = None
|
||||||
current_speed: float | None = None
|
current_speed: float | None = None
|
||||||
current_updated: int | None = None
|
current_updated: int | None = None
|
||||||
@@ -138,6 +149,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
info = MediaSessionInfo()
|
info = MediaSessionInfo()
|
||||||
info.package = current_pkg
|
info.package = current_pkg
|
||||||
info.playing = current_state == 3
|
info.playing = current_state == 3
|
||||||
|
info.buffering = current_state == 6
|
||||||
info.paused = current_state == 2
|
info.paused = current_state == 2
|
||||||
info.position = current_position
|
info.position = current_position
|
||||||
info.speed = current_speed
|
info.speed = current_speed
|
||||||
@@ -158,13 +170,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
current_meta.package = current_pkg
|
current_meta.package = current_pkg
|
||||||
sessions.append(current_meta)
|
sessions.append(current_meta)
|
||||||
|
|
||||||
# Sort: playing first, then paused, then others
|
# Sort: playing first, then buffering (mid-playback), then paused, then
|
||||||
|
# others — a buffering session outranks a paused one (t55).
|
||||||
def sort_key(s: MediaSessionInfo) -> int:
|
def sort_key(s: MediaSessionInfo) -> int:
|
||||||
if s.playing:
|
if s.playing:
|
||||||
return 0
|
return 0
|
||||||
if s.paused:
|
if s.buffering:
|
||||||
return 1
|
return 1
|
||||||
|
if s.paused:
|
||||||
return 2
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
sessions.sort(key=sort_key)
|
sessions.sort(key=sort_key)
|
||||||
return sessions
|
return sessions
|
||||||
@@ -206,8 +221,9 @@ def parse_media_session(
|
|||||||
Returns the best match from all active sessions:
|
Returns the best match from all active sessions:
|
||||||
1. The session whose package matches the foreground app (if provided)
|
1. The session whose package matches the foreground app (if provided)
|
||||||
2. The session that is currently playing (state=3)
|
2. The session that is currently playing (state=3)
|
||||||
3. The session that is paused (state=2)
|
3. The session that is buffering (state=6)
|
||||||
4. Any active session with metadata
|
4. The session that is paused (state=2)
|
||||||
|
5. Any active session with metadata
|
||||||
"""
|
"""
|
||||||
sessions = parse_all_media_sessions(dump)
|
sessions = parse_all_media_sessions(dump)
|
||||||
if not sessions:
|
if not sessions:
|
||||||
@@ -218,6 +234,5 @@ def parse_media_session(
|
|||||||
for s in sessions:
|
for s in sessions:
|
||||||
if s.package == foreground_package:
|
if s.package == foreground_package:
|
||||||
return s
|
return s
|
||||||
|
# Already sorted: playing > buffering > paused > other
|
||||||
# Already sorted: playing > paused > other
|
|
||||||
return sessions[0]
|
return sessions[0]
|
||||||
@@ -51,6 +51,8 @@ def running_apps_from_tasks(
|
|||||||
if session:
|
if session:
|
||||||
if session.playing:
|
if session.playing:
|
||||||
app["state"] = "playing"
|
app["state"] = "playing"
|
||||||
|
elif session.buffering:
|
||||||
|
app["state"] = "buffering"
|
||||||
elif session.paused:
|
elif session.paused:
|
||||||
app["state"] = "paused"
|
app["state"] = "paused"
|
||||||
else:
|
else:
|
||||||
@@ -66,6 +68,8 @@ def running_apps_from_tasks(
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
if cached.playing:
|
if cached.playing:
|
||||||
app["last_known_state"] = "playing"
|
app["last_known_state"] = "playing"
|
||||||
|
elif cached.buffering:
|
||||||
|
app["last_known_state"] = "buffering"
|
||||||
elif cached.paused:
|
elif cached.paused:
|
||||||
app["last_known_state"] = "paused"
|
app["last_known_state"] = "paused"
|
||||||
if cached.title:
|
if cached.title:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -13,8 +13,13 @@
|
|||||||
"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.12",
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
"_androidtvremote2._tcp.local."
|
"_androidtvremote2._tcp.local."
|
||||||
|
],
|
||||||
|
"integration_type": "device",
|
||||||
|
"loggers": [
|
||||||
|
"adb_shell",
|
||||||
|
"androidtvremote2"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -24,7 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from 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,12 @@ 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_ADB_ENABLED,
|
||||||
|
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__)
|
||||||
|
|
||||||
@@ -27,6 +29,10 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Nvidia Shield sensor entities."""
|
"""Set up the Nvidia Shield sensor entities."""
|
||||||
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
||||||
|
if entry.options.get(CONF_ADB_ENABLED, True):
|
||||||
|
# discovery is an ADB feature; with the transport off there is nothing
|
||||||
|
# to enumerate and the entity would only ever report "unavailable"
|
||||||
|
entities.append(NvidiaShieldAppsSensor(hass, entry))
|
||||||
|
|
||||||
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
||||||
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
||||||
@@ -39,6 +45,10 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
# G12 (t30): poll-driven attributes never belong in the database
|
||||||
|
_unrecorded_attributes = frozenset(
|
||||||
|
{"app_id", "app_name", "playback_state", "installed_apps"}
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
@@ -63,17 +73,16 @@ 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_name = "Current App"
|
_attr_translation_key = "current_app"
|
||||||
_attr_icon = "mdi:application"
|
_attr_icon = "mdi:application"
|
||||||
|
|
||||||
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:
|
||||||
@@ -81,34 +90,27 @@ 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 package of the app that is on screen (t41, t55).
|
||||||
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 (both via ``manager.foreground_app``) → the most
|
||||||
|
recent visible ADB recent task. The recents tier is a last resort for
|
||||||
|
a moment when neither live signal has reported yet; it keeps the
|
||||||
|
sensor from going None while ADB is still warming up.
|
||||||
|
"""
|
||||||
manager = self._manager()
|
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
|
||||||
@@ -117,26 +119,145 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
|||||||
return running[0].get("package")
|
return running[0].get("package")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
"""The resolved friendly name (t40), not the raw package id.
|
||||||
|
|
||||||
|
Exactly the ``media_player.app_name`` chain via the shared
|
||||||
|
``resolve_app_name``: apps map → cached manifest label → the package
|
||||||
|
text itself. A bare or unknown package shows its package text rather
|
||||||
|
than None, like the media player's app_name.
|
||||||
|
|
||||||
|
This is why ``_package`` must be the CURRENT app and the label fetch
|
||||||
|
must be driven off it (t55): the device-manifest label is resolved on
|
||||||
|
demand for exactly the package passed here, so a live run showing a
|
||||||
|
package id means the label tier never answered — see
|
||||||
|
``ShieldConnectionManager.app_label`` for the caching rule that makes
|
||||||
|
a miss retry instead of being stamped as an eternal "no label".
|
||||||
|
"""
|
||||||
|
manager = self._manager()
|
||||||
|
return resolve_app_name(
|
||||||
|
self._package(),
|
||||||
|
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||||
|
manager.app_label if manager is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
"""The raw package stays visible here (t40) next to the shown name."""
|
||||||
if state:
|
package = self._package()
|
||||||
return {
|
attrs: dict = {}
|
||||||
"app_id": state.attributes.get("app_id"),
|
if package:
|
||||||
"app_name": state.attributes.get("app_name"),
|
attrs["app_id"] = package
|
||||||
}
|
name = self.native_value
|
||||||
|
if name:
|
||||||
|
attrs["app_name"] = name
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldAppsSensor(NvidiaShieldSensorBase):
|
||||||
|
"""The apps installed on the Shield (t57) — a dashboard-facing surface.
|
||||||
|
|
||||||
|
The state is the app COUNT and the ``installed_apps`` attribute is the
|
||||||
|
table: ``[{package, name, icon}, ...]``, exactly what the Configure page's
|
||||||
|
Apps sub-screen edits. It is the answer to "it doesn't find all the apps":
|
||||||
|
the list comes from the device (``pm list packages -3`` plus the active
|
||||||
|
launcher), labelled through the cached ``pm dump`` path.
|
||||||
|
|
||||||
|
Only ADB-enabled entries get one. Discovery is a handful of shells, so it
|
||||||
|
runs once when the entity is added and then only when the table has gone
|
||||||
|
stale: ``async_update`` goes through the manager's TTL cache, so a poll on a
|
||||||
|
fresh table issues NO shell. A sweep that could not run is NOT an answer
|
||||||
|
(t62 F1): it is recorded as ``unknown`` — or shown as ``not_connected``
|
||||||
|
while the transport itself is down — and retried on the next update instead
|
||||||
|
of being frozen as a stamped-empty table for APP_LIST_TTL, while a
|
||||||
|
sweep that ran and found nothing IS the answer ``ok`` with a count of 0. On
|
||||||
|
top of that the user can ask for a refresh with the standard
|
||||||
|
``homeassistant.update_entity`` action, and the Configure page runs the same
|
||||||
|
sweep from its discovery row. With ADB off, or unreachable, the attributes
|
||||||
|
say why and the state is 0 rather than raising.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_translation_key = "installed_apps"
|
||||||
|
_attr_icon = "mdi:apps"
|
||||||
|
# Polling is what makes the standard `homeassistant.update_entity` action
|
||||||
|
# work for it — the user-facing "refresh the app list" trigger. The poll is
|
||||||
|
# free while the table is fresh: the manager returns the cache and issues no
|
||||||
|
# shell until APP_LIST_TTL expires. A sweep that COULD NOT run leaves no
|
||||||
|
# fresh table (t62 F1), so a later poll retries it — that retry is the
|
||||||
|
# recovery the old stamped-empty answer blocked for up to six hours.
|
||||||
|
_attr_should_poll = True
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
super().__init__(hass, entry)
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps"
|
||||||
|
|
||||||
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
|
manager = getattr(self._entry, "runtime_data", None)
|
||||||
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
if manager is not None:
|
if manager is not None:
|
||||||
running = manager.running_apps
|
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||||
if running:
|
# one discovery per entity lifetime; a load is already a wake-up moment,
|
||||||
return {"app_id": running[0].get("package")}
|
# and nothing here runs on a state poll
|
||||||
return {}
|
self.hass.async_create_task(self.async_update())
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_store_update(self) -> None:
|
||||||
|
if self.hass is not None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""Refresh the table when there is no fresh answer (t57, t62 F1).
|
||||||
|
|
||||||
|
HA's own poll (``_attr_should_poll``) and an explicit
|
||||||
|
``homeassistant.update_entity`` both land here; the manager's TTL makes
|
||||||
|
it a no-op while the table is fresh, and a retry while it is not —
|
||||||
|
including after a sweep that could not run.
|
||||||
|
"""
|
||||||
|
manager = self._manager()
|
||||||
|
if manager is None:
|
||||||
|
return
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> int:
|
||||||
|
manager = self._manager()
|
||||||
|
return len(manager.installed_apps()) if manager is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict:
|
||||||
|
"""The table itself, plus why it may be empty (never a crash).
|
||||||
|
|
||||||
|
``discovery`` is the honest reason (t62 F1): ``ok`` means the table IS
|
||||||
|
the answer, including a genuinely empty one, while ``unknown`` means
|
||||||
|
the last sweep could not run — so an empty list says nothing about the
|
||||||
|
device and the next update retries.
|
||||||
|
"""
|
||||||
|
manager = self._manager()
|
||||||
|
if manager is None:
|
||||||
|
return {"installed_apps": [], "discovery": "unavailable"}
|
||||||
|
apps = manager.installed_apps()
|
||||||
|
attrs = {"installed_apps": apps}
|
||||||
|
if apps:
|
||||||
|
attrs["discovery"] = "ok"
|
||||||
|
elif not self._entry.options.get(CONF_ADB_ENABLED, True):
|
||||||
|
attrs["discovery"] = "adb_disabled"
|
||||||
|
elif not manager.connected:
|
||||||
|
attrs["discovery"] = "not_connected"
|
||||||
|
else:
|
||||||
|
# "ok" here is a real empty answer, "unknown" a sweep that could
|
||||||
|
# not run, "pending" one that has not completed yet.
|
||||||
|
attrs["discovery"] = manager.installed_apps_status
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
"""What's currently playing via Jellyfin."""
|
"""What's currently playing via Jellyfin."""
|
||||||
|
|
||||||
_attr_name = "Now Playing"
|
_attr_translation_key = "now_playing"
|
||||||
_attr_icon = "mdi:play-circle"
|
_attr_icon = "mdi:play-circle"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
adb_command:
|
||||||
|
# Both fields required — integrating targeting must not be optional
|
||||||
|
# (docs/dev_101_services: no default target when none specified).
|
||||||
|
name: ADB command
|
||||||
|
description: Send a raw ADB shell command to the persistent connection of
|
||||||
|
an Nvidia Shield entry (the entry must have the adb_command_enabled
|
||||||
|
option on; the integration gates USE, not registration).
|
||||||
|
fields:
|
||||||
|
command:
|
||||||
|
name: Command
|
||||||
|
description: The shell command to run on the device, e.g.
|
||||||
|
"dumpsys power | head -5".
|
||||||
|
required: true
|
||||||
|
example: "dumpsys power | head -5"
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
entity_id:
|
||||||
|
name: Entity
|
||||||
|
description: The Nvidia Shield media player entity to target.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
integration: nvidia_shield
|
||||||
|
domain: media_player
|
||||||
@@ -3,103 +3,315 @@
|
|||||||
"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 — 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, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
"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 — 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 — 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."
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Finish setup",
|
||||||
|
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"connection": "Connection",
|
||||||
|
"adb_pair": "Set up ADB",
|
||||||
|
"protocol_pair": "Pair the remote protocol",
|
||||||
|
"finish": "Create the entry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Connection",
|
||||||
|
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"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 — 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 — 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 — 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 — change it if the Shield moved.",
|
||||||
|
"skip_pairing": "Finishes setup with the remote protocol off — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"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 — 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 — 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 — 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.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
"reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||||
|
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
|
"yaml_seed_only": {},
|
||||||
|
"yaml_seed": {
|
||||||
|
"title": "YAML succeeded earlier",
|
||||||
|
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 change takes effect. {discovery}.",
|
||||||
"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 — no extra setup needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||||
"receiver_input": "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 — 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 — 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) — 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 — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||||
|
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"connection": "Main setup",
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"apps": "Apps",
|
||||||
|
"discover": "Discover installed apps",
|
||||||
|
"scripts": "Scripts",
|
||||||
|
"finish": "Save and close"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Main setup",
|
||||||
|
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"adb_store_app_info": "Show title and artist",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
|
"logcat_events": "Watch app launches over logcat",
|
||||||
|
"adb_command_enabled": "Allow the adb_command action",
|
||||||
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
|
"power_entity": "Power switch"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Change it only if the Shield moved.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
|
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
|
||||||
|
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
|
||||||
|
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
|
"power_entity": "A smart plug switch, if you want reliable on/off detection."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Apps",
|
||||||
|
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||||
|
"data": {
|
||||||
|
"app_name_map": "App names and icons"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"title": "Scripts",
|
||||||
|
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||||
|
"data": {
|
||||||
|
"remote_command_script": "Script for remote.send_command",
|
||||||
|
"script_turn_on": "turn_on script",
|
||||||
|
"script_turn_off": "turn_off script",
|
||||||
|
"script_volume_up": "volume_up script",
|
||||||
|
"script_volume_down": "volume_down script",
|
||||||
|
"script_volume_set": "volume_set script",
|
||||||
|
"script_volume_mute": "volume_mute script",
|
||||||
|
"script_select_source": "select_source script",
|
||||||
|
"channel_digit_delay": "Delay between channel digits (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
|
||||||
|
"channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.",
|
||||||
|
"channel_send_enter": "Turn on if your channel app needs ENTER after the digits."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty — nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down — command not sent."
|
||||||
|
},
|
||||||
|
"unknown_source": {
|
||||||
|
"title": "Unknown source",
|
||||||
|
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list — the app names from the Apps map, or the built-in list when no map is set."
|
||||||
|
},
|
||||||
|
"command_script_failed": {
|
||||||
|
"title": "Command script failed",
|
||||||
|
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"current_app": {
|
||||||
|
"name": "Current app"
|
||||||
|
},
|
||||||
|
"now_playing": {
|
||||||
|
"name": "Now playing"
|
||||||
|
},
|
||||||
|
"installed_apps": {
|
||||||
|
"name": "Installed apps"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,103 +3,315 @@
|
|||||||
"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 — 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, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
"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 — 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 — 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."
|
},
|
||||||
|
"menu": {
|
||||||
|
"title": "Finish setup",
|
||||||
|
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"connection": "Connection",
|
||||||
|
"adb_pair": "Set up ADB",
|
||||||
|
"protocol_pair": "Pair the remote protocol",
|
||||||
|
"finish": "Create the entry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Connection",
|
||||||
|
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"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 — 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 — 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 — 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 — change it if the Shield moved.",
|
||||||
|
"skip_pairing": "Finishes setup with the remote protocol off — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reauth_confirm": {
|
"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 — 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 — 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 — 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.",
|
||||||
"zeroconf_no_host": "The discovery event carried no usable address."
|
"reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
|
||||||
|
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address.",
|
||||||
|
"yaml_seed_only": {},
|
||||||
|
"yaml_seed": {
|
||||||
|
"title": "YAML succeeded earlier",
|
||||||
|
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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 change takes effect. {discovery}.",
|
||||||
"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 — no extra setup needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||||
"receiver_input": "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 — 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 — 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) — 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 — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||||
|
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||||
|
},
|
||||||
|
"menu_options": {
|
||||||
|
"connection": "Main setup",
|
||||||
|
"receiver": "Receiver",
|
||||||
|
"apps": "Apps",
|
||||||
|
"discover": "Discover installed apps",
|
||||||
|
"scripts": "Scripts",
|
||||||
|
"finish": "Save and close"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"title": "Main setup",
|
||||||
|
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
|
||||||
|
"data": {
|
||||||
|
"host": "The Shield's IP address",
|
||||||
|
"adb_port": "ADB port",
|
||||||
|
"adb_enabled": "Read state over ADB",
|
||||||
|
"adb_store_app_info": "Show title and artist",
|
||||||
|
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||||
|
"logcat_events": "Watch app launches over logcat",
|
||||||
|
"adb_command_enabled": "Allow the adb_command action",
|
||||||
|
"jellyfin_entity": "Jellyfin media player",
|
||||||
|
"power_entity": "Power switch"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The Shield's IP address. Change it only if the Shield moved.",
|
||||||
|
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||||
|
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||||
|
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||||
|
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
|
||||||
|
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
|
||||||
|
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
|
||||||
|
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||||
|
"power_entity": "A smart plug switch, if you want reliable on/off detection."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"receiver": {
|
||||||
|
"title": "Receiver",
|
||||||
|
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
|
||||||
|
"data": {
|
||||||
|
"receiver_entity": "Receiver",
|
||||||
|
"receiver_input": "Receiver input"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||||
|
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"title": "Apps",
|
||||||
|
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list.",
|
||||||
|
"data": {
|
||||||
|
"app_name_map": "App names and icons"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"title": "Scripts",
|
||||||
|
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
|
||||||
|
"data": {
|
||||||
|
"remote_command_script": "Script for remote.send_command",
|
||||||
|
"script_turn_on": "turn_on script",
|
||||||
|
"script_turn_off": "turn_off script",
|
||||||
|
"script_volume_up": "volume_up script",
|
||||||
|
"script_volume_down": "volume_down script",
|
||||||
|
"script_volume_set": "volume_set script",
|
||||||
|
"script_volume_mute": "volume_mute script",
|
||||||
|
"script_select_source": "select_source script",
|
||||||
|
"channel_digit_delay": "Delay between channel digits (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
|
||||||
|
"channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.",
|
||||||
|
"channel_send_enter": "Turn on if your channel app needs ENTER after the digits."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"adb_command": {
|
||||||
|
"name": "ADB command",
|
||||||
|
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||||
|
"fields": {
|
||||||
|
"command": {
|
||||||
|
"name": "Command",
|
||||||
|
"description": "The shell command to run on the device."
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "Entity",
|
||||||
|
"description": "The Nvidia Shield media player to target."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"invalid_command": {
|
||||||
|
"title": "Empty command",
|
||||||
|
"message": "The command field is empty — nothing was sent."
|
||||||
|
},
|
||||||
|
"unsupported_target": {
|
||||||
|
"title": "Unsupported entity for adb_command",
|
||||||
|
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) — command not sent."
|
||||||
|
},
|
||||||
|
"adb_unavailable": {
|
||||||
|
"title": "ADB unavailable",
|
||||||
|
"message": "The ADB connection of {entity_id} is down — command not sent."
|
||||||
|
},
|
||||||
|
"unknown_source": {
|
||||||
|
"title": "Unknown source",
|
||||||
|
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list — the app names from the Apps map, or the built-in list when no map is set."
|
||||||
|
},
|
||||||
|
"command_script_failed": {
|
||||||
|
"title": "Command script failed",
|
||||||
|
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"current_app": {
|
||||||
|
"name": "Current app"
|
||||||
|
},
|
||||||
|
"now_playing": {
|
||||||
|
"name": "Now playing"
|
||||||
|
},
|
||||||
|
"installed_apps": {
|
||||||
|
"name": "Installed apps"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+157
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Resolve the version this push should release (t42).
|
||||||
|
#
|
||||||
|
# Why a script: this decision is what makes a release visible to HACS, and every
|
||||||
|
# wrong answer is silent — the tag and the release still exist, only the manifest
|
||||||
|
# never moves. So the logic lives here, where tests/test_release_versioning.py
|
||||||
|
# drives every branch locally, instead of inline in a workflow nobody can run.
|
||||||
|
#
|
||||||
|
# Resolution (M = manifest version, D = today in UTC, tags = existing v-tags):
|
||||||
|
# 1. M is tagged AND that tag is the commit being released
|
||||||
|
# -> RELEASE=no (SOURCE=released): a re-run, this commit is already out
|
||||||
|
# 2. M is not tagged -> VERSION=M (SOURCE=manifest)
|
||||||
|
# an unreleased manifest version wins: bump it to force a release
|
||||||
|
# 3. D is not tagged -> VERSION=D (SOURCE=date)
|
||||||
|
# the one automatic release per UTC day
|
||||||
|
# 4. otherwise -> VERSION=D.N (SOURCE=same-day)
|
||||||
|
# N is the LOWEST untagged N >= 2, so further releases in the same UTC day
|
||||||
|
# become 2026.10.11.2, 2026.10.11.3, … instead of silently doing nothing
|
||||||
|
#
|
||||||
|
# The workflow writes the resolved VERSION back into the manifest whenever it
|
||||||
|
# differs, then tags THAT commit, so the released artifact and its tag always
|
||||||
|
# agree. The defect this fixes: v2026.10.09 shipped while master's manifest still
|
||||||
|
# said 2026.10.08, leaving HACS comparing against a version that never moved.
|
||||||
|
#
|
||||||
|
# Usage: scripts/determine-release-version.sh [--manifest PATH] [--today YYYY.MM.DD]
|
||||||
|
# [--tags-from FILE] [--head SHA]
|
||||||
|
#
|
||||||
|
# --manifest manifest to read (default: custom_components/nvidia_shield/manifest.json)
|
||||||
|
# --today override the UTC date — for tests and dry runs only
|
||||||
|
# --tags-from read "TAG [SHA]" lines instead of asking git — for tests and dry runs
|
||||||
|
# --head the commit being released (default: git rev-parse HEAD)
|
||||||
|
#
|
||||||
|
# Stdout, and $GITHUB_OUTPUT as well when it is set (which is what the workflow
|
||||||
|
# consumes): VERSION, TAG, SOURCE, BUMP, RELEASE. Commentary goes to stderr.
|
||||||
|
# RELEASE=no is the explicit no-op signal: nothing may be tagged or published.
|
||||||
|
# Exit status is 1 only for a usage or manifest error, so "no release" is not a
|
||||||
|
# failed job.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MANIFEST="custom_components/nvidia_shield/manifest.json"
|
||||||
|
TAGS_FROM=""
|
||||||
|
HEAD_SHA=""
|
||||||
|
TODAY=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--manifest) MANIFEST="${2:?--manifest needs a path}"; shift 2 ;;
|
||||||
|
--today) TODAY="${2:?--today needs YYYY.MM.DD}"; shift 2 ;;
|
||||||
|
--tags-from) TAGS_FROM="${2:?--tags-from needs a path}"; shift 2 ;;
|
||||||
|
--head) HEAD_SHA="${2:?--head needs a sha}"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
note() { printf '%s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
[[ -f "$MANIFEST" ]] || { note "manifest not found: $MANIFEST"; exit 1; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { note "jq is required to read the manifest"; exit 1; }
|
||||||
|
|
||||||
|
MANIFEST_VERSION="$(jq -r '.version // empty' "$MANIFEST")"
|
||||||
|
[[ -n "$MANIFEST_VERSION" ]] || { note "no .version in $MANIFEST"; exit 1; }
|
||||||
|
# Tolerate a hand-written leading "v" (the tag adds its own); anything else that
|
||||||
|
# is not YYYY.MM.DD[.N] is a mistake worth failing for.
|
||||||
|
MANIFEST_VERSION="${MANIFEST_VERSION#v}"
|
||||||
|
[[ "$MANIFEST_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || {
|
||||||
|
note "manifest version '$MANIFEST_VERSION' is not YYYY.MM.DD[.N]"; exit 1; }
|
||||||
|
|
||||||
|
if [[ -n "$TODAY" ]]; then
|
||||||
|
[[ "$TODAY" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || { note "--today must be YYYY.MM.DD"; exit 1; }
|
||||||
|
DATE_VERSION="$TODAY"
|
||||||
|
else
|
||||||
|
# UTC, always: a runner's local timezone must never decide a version number.
|
||||||
|
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$TAGS_FROM" ]]; then
|
||||||
|
[[ -f "$TAGS_FROM" ]] || { note "tag list not found: $TAGS_FROM"; exit 1; }
|
||||||
|
else
|
||||||
|
command -v git >/dev/null 2>&1 || { note "git is required to list tags"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
tag_exists() {
|
||||||
|
if [[ -n "$TAGS_FROM" ]]; then
|
||||||
|
awk -v wanted="$1" '$1 == wanted { found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||||
|
else
|
||||||
|
git rev-parse -q --verify "refs/tags/$1" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tag_sha() {
|
||||||
|
if [[ -n "$TAGS_FROM" ]]; then
|
||||||
|
awk -v wanted="$1" '$1 == wanted && NF > 1 { print $2; found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||||
|
else
|
||||||
|
git rev-parse -q --verify "refs/tags/$1^{commit}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$HEAD_SHA" ]] && [[ -z "$TAGS_FROM" ]]; then
|
||||||
|
HEAD_SHA="$(git rev-parse -q --verify HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
SOURCE=""
|
||||||
|
RELEASE="yes"
|
||||||
|
|
||||||
|
if tag_exists "v${MANIFEST_VERSION}" && [[ -n "$HEAD_SHA" ]] \
|
||||||
|
&& [[ "$(tag_sha "v${MANIFEST_VERSION}" 2>/dev/null || true)" == "$HEAD_SHA" ]]; then
|
||||||
|
VERSION="$MANIFEST_VERSION"; SOURCE="released"; RELEASE="no"
|
||||||
|
note "v${VERSION} is already tagged on this commit — nothing to release"
|
||||||
|
elif ! tag_exists "v${MANIFEST_VERSION}"; then
|
||||||
|
VERSION="$MANIFEST_VERSION"; SOURCE="manifest"
|
||||||
|
elif ! tag_exists "v${DATE_VERSION}"; then
|
||||||
|
VERSION="$DATE_VERSION"; SOURCE="date"
|
||||||
|
else
|
||||||
|
N=2
|
||||||
|
while tag_exists "v${DATE_VERSION}.${N}"; do N=$((N + 1)); done
|
||||||
|
VERSION="${DATE_VERSION}.${N}"; SOURCE="same-day"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RELEASE" == "yes" ]] && tag_exists "v${VERSION}"; then
|
||||||
|
# Backstop, not a normal path: the resolution above only ever picks an
|
||||||
|
# untagged version, so reaching this means the tag set changed underneath us
|
||||||
|
# (a race with a parallel run — the concurrency group is the primary guard).
|
||||||
|
# Never tag or publish something that already exists.
|
||||||
|
RELEASE="no"; SOURCE="tagged"
|
||||||
|
note "v${VERSION} already exists — nothing to release"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUMP="no"
|
||||||
|
if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then
|
||||||
|
BUMP="yes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RELEASE" == "yes" ]]; then
|
||||||
|
note "releasing v${VERSION} (${SOURCE}); manifest says ${MANIFEST_VERSION}, bump=${BUMP}"
|
||||||
|
else
|
||||||
|
note "no release (${SOURCE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
printf '%s=%s\n' "$1" "$2"
|
||||||
|
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||||
|
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
emit VERSION "$VERSION"
|
||||||
|
emit TAG "v${VERSION}"
|
||||||
|
emit SOURCE "$SOURCE"
|
||||||
|
emit BUMP "$BUMP"
|
||||||
|
emit RELEASE "$RELEASE"
|
||||||
@@ -50,3 +50,4 @@ _load_module(
|
|||||||
# Stdlib-only integration modules.
|
# 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")
|
||||||
|
|
||||||
+444
-21
@@ -10,11 +10,15 @@ 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",
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.exceptions",
|
||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.components",
|
"homeassistant.components",
|
||||||
@@ -23,8 +27,10 @@ ENTITIES_STUB_NAMES = (
|
|||||||
"homeassistant.components.button",
|
"homeassistant.components.button",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_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",
|
||||||
@@ -36,12 +42,54 @@ ENTITIES_STUB_NAMES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EntitySelectorStub:
|
||||||
|
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
||||||
|
|
||||||
|
Real HA answers a cleared entity field with "Schema validation failed at …"
|
||||||
|
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
|
||||||
|
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
|
||||||
|
values to prove it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config=None):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def __call__(self, data):
|
||||||
|
if not isinstance(data, str) or not data.strip():
|
||||||
|
raise ValueError("Entity is not a valid entity id")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def install_selector_faces(selector_module) -> None:
|
||||||
|
"""Populate a ``homeassistant.helpers.selector`` stub module.
|
||||||
|
|
||||||
|
One definition for every installer (entity_stublib and the four per-file
|
||||||
|
installers in the flow tests), so the stubs cannot drift apart.
|
||||||
|
"""
|
||||||
|
selector_module.EntitySelector = EntitySelectorStub
|
||||||
|
|
||||||
|
class _Config(types.SimpleNamespace):
|
||||||
|
"""Selector configs keep their kwargs (real HA stores them as fields).
|
||||||
|
|
||||||
|
The stub needs them inspectable: which domains a selector offers is a
|
||||||
|
user-visible contract (t56 — the Receiver field accepts a switch), and a
|
||||||
|
lambda throwing the value away cannot pin it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
selector_module.EntitySelectorConfig = _Config
|
||||||
|
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||||
|
selector_module.SelectSelectorConfig = _Config
|
||||||
|
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerState(enum.StrEnum):
|
class MediaPlayerState(enum.StrEnum):
|
||||||
OFF = "off"
|
OFF = "off"
|
||||||
ON = "on"
|
ON = "on"
|
||||||
IDLE = "idle"
|
IDLE = "idle"
|
||||||
PLAYING = "playing"
|
PLAYING = "playing"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
|
STANDBY = "standby"
|
||||||
|
BUFFERING = "buffering"
|
||||||
|
|
||||||
|
|
||||||
class MediaPlayerEntityFeature(enum.IntFlag):
|
class MediaPlayerEntityFeature(enum.IntFlag):
|
||||||
@@ -57,36 +105,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,
|
||||||
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.children = children or []
|
self.children = children
|
||||||
|
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:
|
||||||
@@ -105,15 +215,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")
|
||||||
@@ -121,19 +252,141 @@ def install() -> None:
|
|||||||
|
|
||||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
async def _stub_set_unique_id(self, unique_id):
|
||||||
|
# hub shape: record; a full flow test file scopes its own version
|
||||||
|
self._unique_id = unique_id
|
||||||
|
|
||||||
|
def _noop_abort(self, *, error=None, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _ConfigFlowStub:
|
||||||
|
"""ConfigFlow shape used by entity tests (t29 G3a API included).
|
||||||
|
|
||||||
|
Late loads under the hub stubs (e.g. test_init designs run AFTER the
|
||||||
|
other flow files) still get the base plumbing the flow modules rely
|
||||||
|
on — show/create/abort/unique-id — so the flow classes never import
|
||||||
|
against a bare base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async_set_unique_id = _stub_set_unique_id
|
||||||
|
_abort_if_unique_id_configured = _noop_abort
|
||||||
|
|
||||||
|
async def _async_handle_discovery_without_unique_id(self):
|
||||||
|
# t37 D3: the real helper is a COROUTINE and core awaits it — a
|
||||||
|
# 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,
|
||||||
|
description_placeholders=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"type": "form",
|
||||||
|
"step_id": step_id,
|
||||||
|
"errors": dict(errors or {}),
|
||||||
|
"data_schema": data_schema,
|
||||||
|
"description_placeholders": dict(description_placeholders or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
def async_abort(self, *, reason):
|
||||||
|
return {"type": "abort", "reason": reason}
|
||||||
|
|
||||||
|
def _get_reauth_entry(self, entry=None):
|
||||||
|
if entry is not None:
|
||||||
|
return entry
|
||||||
|
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, ...)
|
||||||
|
# which updates, schedules the reload and aborts; the stub records both.
|
||||||
|
|
||||||
|
def async_update_reload_and_abort(self, entry, **kwargs):
|
||||||
|
"""Real-HA semantics: update, schedule the reload, abort."""
|
||||||
|
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
|
||||||
|
for key, value in updates.items():
|
||||||
|
if key in ("options", "data", "unique_id"):
|
||||||
|
setattr(entry, key, value)
|
||||||
|
else:
|
||||||
|
getattr(entry, "options", {}).setdefault(key, value) # best effort
|
||||||
|
scheduler = getattr(self.hass, "config_entries", None)
|
||||||
|
if scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry.entry_id)
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
|
def _config_flow_init(self, *args, **kwargs):
|
||||||
|
self.context = getattr(self, "context", {}) or {}
|
||||||
|
self.hass = getattr(self, "hass", None)
|
||||||
|
|
||||||
_config_flow_base = type(
|
_config_flow_base = type(
|
||||||
"ConfigFlow",
|
"ConfigFlow",
|
||||||
(),
|
(_ConfigFlowStub,),
|
||||||
{
|
{
|
||||||
"__init_subclass__": classmethod(
|
"__init_subclass__": classmethod(
|
||||||
lambda cls, domain=None, **kwargs: None
|
lambda cls, domain=None, **kwargs: None
|
||||||
),
|
),
|
||||||
|
"__init__": _config_flow_init,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
config_entries.ConfigFlow = _config_flow_base
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _OptionsFlowStub:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_entry = None
|
||||||
|
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
# real HA merges hints; the stub records them on the schema dict
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class _OptionsFlowWithReload(_OptionsFlowStub):
|
||||||
|
"""Options flow that reloads after a successful save (real HA)."""
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _OptionsFlowStub
|
||||||
|
config_entries.SOURCE_IMPORT = "import"
|
||||||
|
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||||
|
|
||||||
|
class HomeAssistantErrorStubError(Exception):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||||
|
self.translation_domain = kwargs.get("translation_domain")
|
||||||
|
self.translation_key = kwargs.get("translation_key")
|
||||||
|
self.translation_placeholders = kwargs.get("translation_placeholders")
|
||||||
|
|
||||||
|
class ServiceValidationErrorStubError(HomeAssistantErrorStubError):
|
||||||
|
"""Real HA: ServiceValidationError derives from HomeAssistantError (t44)."""
|
||||||
|
|
||||||
|
# t44: media_player raises HomeAssistantError when a configured command script
|
||||||
|
# fails, so both names have to exist — and with the real inheritance.
|
||||||
|
exceptions.HomeAssistantError = HomeAssistantErrorStubError
|
||||||
|
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||||
|
sys.modules["homeassistant.exceptions"] = exceptions
|
||||||
|
|
||||||
core = types.ModuleType("homeassistant.core")
|
core = types.ModuleType("homeassistant.core")
|
||||||
core.HomeAssistant = object
|
core.HomeAssistant = object
|
||||||
core.callback = lambda fn: fn
|
core.callback = lambda fn: fn
|
||||||
@@ -145,6 +398,10 @@ def install() -> None:
|
|||||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
ha_const.STATE_PLAYING = "playing"
|
ha_const.STATE_PLAYING = "playing"
|
||||||
ha_const.STATE_PAUSED = "paused"
|
ha_const.STATE_PAUSED = "paused"
|
||||||
|
# t61: a receiver switch is read and switched by the media player, so the
|
||||||
|
# two switch states have to exist with their real values.
|
||||||
|
ha_const.STATE_ON = "on"
|
||||||
|
ha_const.STATE_OFF = "off"
|
||||||
ha_const.Platform = types.SimpleNamespace(
|
ha_const.Platform = types.SimpleNamespace(
|
||||||
MEDIA_PLAYER="media_player",
|
MEDIA_PLAYER="media_player",
|
||||||
SENSOR="sensor",
|
SENSOR="sensor",
|
||||||
@@ -161,9 +418,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
|
||||||
|
|
||||||
@@ -174,6 +434,11 @@ def install() -> None:
|
|||||||
button = types.ModuleType("homeassistant.components.button")
|
button = types.ModuleType("homeassistant.components.button")
|
||||||
button.ButtonEntity = type("ButtonEntity", (), {})
|
button.ButtonEntity = type("ButtonEntity", (), {})
|
||||||
sys.modules["homeassistant.components.button"] = button
|
sys.modules["homeassistant.components.button"] = button
|
||||||
|
sensor_platform = types.ModuleType("homeassistant.components.sensor")
|
||||||
|
sensor_platform.SensorEntity = type("SensorEntity", (), {})
|
||||||
|
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
|
||||||
|
sys.modules["homeassistant.components.sensor"] = sensor_platform
|
||||||
|
|
||||||
|
|
||||||
helpers = types.ModuleType("homeassistant.helpers")
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
@@ -185,15 +450,32 @@ def install() -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
entity_registry.async_get = lambda hass: _Registry()
|
entity_registry.async_get = lambda hass: _Registry()
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
|
||||||
|
class _DeviceRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.devices: dict[str, object] = {}
|
||||||
|
|
||||||
|
def async_get(self, device_id):
|
||||||
|
return self.devices.get(device_id)
|
||||||
|
|
||||||
|
_device_registry_shared = _DeviceRegistry()
|
||||||
|
|
||||||
|
device_registry.async_get = lambda hass: _device_registry_shared
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
|
|
||||||
helpers.entity_registry = entity_registry
|
helpers.entity_registry = entity_registry
|
||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
|
||||||
|
helpers.device_registry = device_registry
|
||||||
|
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||||
|
|
||||||
event = types.ModuleType("homeassistant.helpers.event")
|
event = types.ModuleType("homeassistant.helpers.event")
|
||||||
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
event.async_call_later = lambda hass, delay, callback_fn: (lambda: 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: "",
|
||||||
@@ -201,32 +483,173 @@ def install() -> None:
|
|||||||
)
|
)
|
||||||
entity = types.ModuleType("homeassistant.helpers.entity")
|
entity = types.ModuleType("homeassistant.helpers.entity")
|
||||||
entity.DeviceInfo = dict
|
entity.DeviceInfo = dict
|
||||||
|
entity.EntityCategory = types.SimpleNamespace(
|
||||||
|
CONFIG="config", DIAGNOSTIC="diagnostic"
|
||||||
|
)
|
||||||
helpers.entity = entity
|
helpers.entity = entity
|
||||||
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()
|
||||||
from conftest import _SHIELD_DIR, _load_module
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
|
||||||
for name in ("helpers", "power", "jellyfin", "media_player", "remote", "diagnostics"):
|
for name in (
|
||||||
|
"helpers",
|
||||||
|
"power",
|
||||||
|
"jellyfin",
|
||||||
|
"sensor",
|
||||||
|
"media_player",
|
||||||
|
"remote",
|
||||||
|
"diagnostics",
|
||||||
|
# t41: button.py was the file nobody loaded — a deleted helper it still
|
||||||
|
# imported shipped silently. It is a platform like the others, so it
|
||||||
|
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
|
||||||
|
# is what keeps this list from having to be complete.
|
||||||
|
"button",
|
||||||
|
"init",
|
||||||
|
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||||
|
if name == "init" and "nsh.init" in sys.modules:
|
||||||
|
continue
|
||||||
if f"nsh.{name}" not in sys.modules:
|
if f"nsh.{name}" not in sys.modules:
|
||||||
_load_module(f"nsh.{name}", _SHIELD_DIR / f"{name}.py")
|
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
||||||
|
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
||||||
return {
|
return {
|
||||||
name: sys.modules[f"nsh.{name}"]
|
name: sys.modules[f"nsh.{name}"]
|
||||||
for name in ("power", "jellyfin", "media_player", "remote", "diagnostics")
|
for name in (
|
||||||
|
"power",
|
||||||
|
"jellyfin",
|
||||||
|
"sensor",
|
||||||
|
"media_player",
|
||||||
|
"remote",
|
||||||
|
"diagnostics",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeState:
|
||||||
|
"""The fields entity code actually reads off an HA ``State`` (t61)."""
|
||||||
|
|
||||||
|
def __init__(self, entity_id, state, attributes=None):
|
||||||
|
self.entity_id = entity_id
|
||||||
|
self.state = state
|
||||||
|
self.attributes = dict(attributes or {})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStates:
|
||||||
|
"""A dict-backed stand-in for HA's StateMachine (t61).
|
||||||
|
|
||||||
|
Only the read shape entities use: ``get(entity_id)`` returns the state
|
||||||
|
object, or None for an entity that does not exist — exactly what the real
|
||||||
|
machine returns. ``set`` is a test's way of giving an entity a state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._states: dict = {}
|
||||||
|
|
||||||
|
def set(self, entity_id, state, attributes=None):
|
||||||
|
self._states[entity_id] = FakeState(entity_id, state, attributes)
|
||||||
|
|
||||||
|
def get(self, entity_id):
|
||||||
|
return self._states.get(entity_id)
|
||||||
|
|
||||||
|
|
||||||
class FakeHass:
|
class FakeHass:
|
||||||
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
"""Sized to entity unit tests: background tasks + no-op call_later.
|
||||||
|
|
||||||
|
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
||||||
|
code that reads a linked entity works unmodified; it is empty until a test
|
||||||
|
sets something.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.states = FakeStates()
|
||||||
|
|
||||||
def async_create_task(self, coro):
|
def async_create_task(self, coro):
|
||||||
return asyncio.create_task(coro)
|
return asyncio.create_task(coro)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+1279
-127
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,443 @@
|
|||||||
|
"""tests/test_command_scripts.py — per-command script overrides (t44).
|
||||||
|
|
||||||
|
The user's remaining universal-media-player dependency: some commands need hardware
|
||||||
|
this integration deliberately does not drive — a speaker switch that owns the
|
||||||
|
volume, an AVR input that has to move with the Shield, a mute that has to hit two
|
||||||
|
devices. One script per command is the escape hatch, and the rule is absolute:
|
||||||
|
|
||||||
|
configured -> that command calls the script, with its variables, and never
|
||||||
|
touches the protocol or ADB path;
|
||||||
|
unset -> the command behaves exactly as it did before this existed.
|
||||||
|
|
||||||
|
The suite is expected to stay quiet about the first case elsewhere: nothing else may
|
||||||
|
start calling services, which is why tests/test_command_routing.py asserts that no
|
||||||
|
service call happens on the native paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_COMMAND_SCRIPTS,
|
||||||
|
CONF_REMOTE_COMMAND_SCRIPT,
|
||||||
|
SCRIPT_VAR_APP_NAME,
|
||||||
|
SCRIPT_VAR_COMMAND,
|
||||||
|
SCRIPT_VAR_DELAY_SECS,
|
||||||
|
SCRIPT_VAR_HOLD_SECS,
|
||||||
|
SCRIPT_VAR_LEVEL,
|
||||||
|
SCRIPT_VAR_MUTED,
|
||||||
|
SCRIPT_VAR_NUM_REPEATS,
|
||||||
|
)
|
||||||
|
from test_command_routing import _remote_entity, make_manager, make_player
|
||||||
|
|
||||||
|
#: Commands with no data to pass — the script is called with the entity only.
|
||||||
|
PLAIN_COMMANDS = ("turn_on", "turn_off", "volume_up", "volume_down")
|
||||||
|
|
||||||
|
WATCH = "script.shield_watch"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="modules", autouse=True)
|
||||||
|
def scoped_modules():
|
||||||
|
"""Stub scope + loaded entity modules for one test, restored afterwards.
|
||||||
|
|
||||||
|
The t7 discipline: without the teardown this file's `nsh.*` modules and stub
|
||||||
|
faces survive into whichever file runs next, and the next file's flow/entity
|
||||||
|
classes bind against the wrong faces (the t44 file-order failure).
|
||||||
|
"""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
try:
|
||||||
|
yield entity_stublib.load_entity_modules()
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def _services(served):
|
||||||
|
"""A hass.services stand-in that records (domain, service, data)."""
|
||||||
|
|
||||||
|
class _RecordingServices:
|
||||||
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
|
served.append((domain, service, data))
|
||||||
|
|
||||||
|
return _RecordingServices()
|
||||||
|
|
||||||
|
|
||||||
|
def _player(modules, scripts=None, *, is_on=None):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
if is_on is not None:
|
||||||
|
player._manager._on_is_on(is_on)
|
||||||
|
if scripts is not None:
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||||
|
player.hass.services = _services(served)
|
||||||
|
return player, served
|
||||||
|
|
||||||
|
|
||||||
|
def _script_call(script=WATCH, variables=None):
|
||||||
|
data = {"entity_id": script}
|
||||||
|
if variables:
|
||||||
|
data["variables"] = variables
|
||||||
|
return ("script", "turn_on", data)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMappedCommands:
|
||||||
|
"""A configured script replaces the native path — for every command."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", PLAIN_COMMANDS)
|
||||||
|
async def test_a_plain_command_calls_its_script_and_nothing_else(
|
||||||
|
self, modules, command
|
||||||
|
):
|
||||||
|
# every native path would have had work to do: ADB is up, and for the
|
||||||
|
# power commands the state gate is open
|
||||||
|
player, served = _player(modules, {command: WATCH}, is_on=command == "turn_off")
|
||||||
|
|
||||||
|
await getattr(player, f"async_{command}")()
|
||||||
|
|
||||||
|
assert served == [_script_call()]
|
||||||
|
assert player._manager._protocol.key_calls == []
|
||||||
|
assert player._manager._poller.media_calls == []
|
||||||
|
|
||||||
|
async def test_volume_set_passes_the_level(self, modules):
|
||||||
|
player, served = _player(modules, {"volume_set": WATCH})
|
||||||
|
|
||||||
|
await player.async_set_volume_level(0.4)
|
||||||
|
|
||||||
|
# exactly the variable name and the value as passed, nothing extra
|
||||||
|
assert served == [_script_call(variables={SCRIPT_VAR_LEVEL: 0.4})]
|
||||||
|
assert player._manager._protocol.key_calls == []
|
||||||
|
assert player._manager._poller.media_calls == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mute", [True, False])
|
||||||
|
async def test_volume_mute_passes_the_requested_state(self, modules, mute):
|
||||||
|
"""A script cannot tell mute from unmute without this."""
|
||||||
|
player, served = _player(modules, {"volume_mute": WATCH})
|
||||||
|
|
||||||
|
await player.async_mute_volume(mute)
|
||||||
|
|
||||||
|
assert served == [_script_call(variables={SCRIPT_VAR_MUTED: mute})]
|
||||||
|
assert player._manager._protocol.key_calls == []
|
||||||
|
assert player._manager._poller.media_calls == []
|
||||||
|
|
||||||
|
async def test_select_source_passes_the_app_name(self, modules):
|
||||||
|
player, served = _player(modules, {"select_source": WATCH})
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert served == [_script_call(variables={SCRIPT_VAR_APP_NAME: "TiviMate"})]
|
||||||
|
|
||||||
|
async def test_the_remote_entity_shares_the_same_turn_on_script(self, modules):
|
||||||
|
"""There are two routes to POWER; one configuration governs both."""
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
served = []
|
||||||
|
entity.hass = SimpleNamespace(services=_services(served))
|
||||||
|
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||||
|
entity._manager._on_is_on(False) # the native path would fire
|
||||||
|
|
||||||
|
await entity.async_turn_on()
|
||||||
|
|
||||||
|
assert served == [_script_call()]
|
||||||
|
assert entity._manager._protocol.key_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnmappedCommands:
|
||||||
|
"""With nothing configured, every command keeps its native path."""
|
||||||
|
|
||||||
|
async def test_no_service_call_happens_at_all(self, modules):
|
||||||
|
player, served = _player(modules, {}, is_on=False)
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
await player.async_volume_up()
|
||||||
|
|
||||||
|
assert served == [] # nothing left the integration
|
||||||
|
|
||||||
|
async def test_turn_on_still_sends_the_protocol_power_key(self, modules):
|
||||||
|
player, _served = _player(modules, {}, is_on=False)
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
|
||||||
|
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||||
|
|
||||||
|
async def test_volume_up_still_uses_the_native_protocol_key(self, modules):
|
||||||
|
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||||
|
|
||||||
|
player, _served = _player(modules, {})
|
||||||
|
|
||||||
|
await player.async_volume_up()
|
||||||
|
|
||||||
|
assert player._manager._protocol.key_calls == [
|
||||||
|
(PROTOCOL_REMOTE_KEYS["volume_up"], "SHORT")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_volume_up_still_falls_through_to_adb(self, modules):
|
||||||
|
"""The second native tier, reached exactly when the protocol is down."""
|
||||||
|
player, _served = _player(modules, {})
|
||||||
|
player._manager._protocol = None
|
||||||
|
|
||||||
|
await player.async_volume_up()
|
||||||
|
|
||||||
|
assert player._manager._poller.media_calls == ["volume_up"]
|
||||||
|
|
||||||
|
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
|
||||||
|
"""Absolute volume exists ONLY through a script (the protocol has no
|
||||||
|
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
|
||||||
|
player, served = _player(modules, {})
|
||||||
|
|
||||||
|
await player.async_set_volume_level(0.4)
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert player._manager._protocol.key_calls == []
|
||||||
|
assert player._manager._poller.media_calls == []
|
||||||
|
|
||||||
|
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
|
||||||
|
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||||
|
|
||||||
|
player, served = _player(modules, {"volume_up": WATCH})
|
||||||
|
|
||||||
|
await player.async_volume_down()
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert player._manager._protocol.key_calls == [
|
||||||
|
(PROTOCOL_REMOTE_KEYS["volume_down"], "SHORT")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestScriptFailures:
|
||||||
|
"""A mapped command that cannot run must be visible, never a silent no-op."""
|
||||||
|
|
||||||
|
async def test_a_raising_script_surfaces_a_clean_error(self, modules):
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
|
||||||
|
player, _served = _player(modules, {"volume_up": WATCH}, is_on=False)
|
||||||
|
|
||||||
|
class _FailingServices:
|
||||||
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
|
raise RuntimeError("speaker switch is offline")
|
||||||
|
|
||||||
|
player.hass.services = _FailingServices()
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError) as raised:
|
||||||
|
await player.async_volume_up()
|
||||||
|
|
||||||
|
# translated (t47): the command, the script and the cause are placeholders
|
||||||
|
assert raised.value.translation_domain == "nvidia_shield"
|
||||||
|
assert raised.value.translation_key == "command_script_failed"
|
||||||
|
assert raised.value.translation_placeholders == {
|
||||||
|
"action": "volume_up",
|
||||||
|
"script": WATCH,
|
||||||
|
"error": "speaker switch is offline",
|
||||||
|
}
|
||||||
|
# ...and it did NOT quietly fall back to the native path
|
||||||
|
assert player._manager._protocol.key_calls == []
|
||||||
|
assert player._manager._poller.media_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionShape:
|
||||||
|
"""The stored option is read through one normalizer, defensively."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"stored",
|
||||||
|
[
|
||||||
|
"script.watch", # not a dict at all
|
||||||
|
{"turn_on": 5}, # not an entity id
|
||||||
|
{"turn_on": " "}, # empty
|
||||||
|
{"bogus_command": WATCH}, # unknown command
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_a_hand_edited_option_never_reaches_the_service_layer(
|
||||||
|
self, modules, stored
|
||||||
|
):
|
||||||
|
player, served = _player(modules, stored, is_on=False)
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||||
|
|
||||||
|
async def test_a_padded_entity_id_is_trimmed(self, modules):
|
||||||
|
player, served = _player(modules, {"turn_on": f" {WATCH} "}, is_on=False)
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
|
||||||
|
assert served == [_script_call()]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeatureSet:
|
||||||
|
"""A script-only command still has to be reachable, so it is advertised."""
|
||||||
|
|
||||||
|
def _features(self, player):
|
||||||
|
return int(player.supported_features)
|
||||||
|
|
||||||
|
def test_volume_set_appears_only_when_configured(self, modules):
|
||||||
|
"""volume_set has no native path at all, so the script is the only reach."""
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules, {})
|
||||||
|
baseline = self._features(player)
|
||||||
|
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
|
||||||
|
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
|
||||||
|
with_volume = self._features(player)
|
||||||
|
|
||||||
|
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
|
||||||
|
# ...and nothing else moved
|
||||||
|
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
|
||||||
|
|
||||||
|
def test_select_source_is_advertised_once_there_is_something_to_offer(
|
||||||
|
self, modules
|
||||||
|
):
|
||||||
|
"""t47: either a script OR a source list is enough — the native path exists
|
||||||
|
now, so an unconfigured integration still has sources to offer."""
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules, {})
|
||||||
|
flagged = self._features(player)
|
||||||
|
|
||||||
|
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||||
|
# a configured script changes nothing about the flag
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
|
||||||
|
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||||
|
|
||||||
|
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
|
||||||
|
player, _served = _player(modules, {})
|
||||||
|
baseline = self._features(player)
|
||||||
|
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||||
|
|
||||||
|
assert self._features(player) == baseline
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_remote_entry_options_are_the_media_player_options(modules):
|
||||||
|
"""Both entities read the same option key — pinned so they cannot drift."""
|
||||||
|
player, _served = _player(modules, {"turn_on": WATCH})
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||||
|
|
||||||
|
assert (
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS]
|
||||||
|
== entity._entry.options[CONF_COMMAND_SCRIPTS]
|
||||||
|
)
|
||||||
|
assert make_manager().entry is None # the harness's manager starts unbound
|
||||||
|
|
||||||
|
|
||||||
|
WATCH_SCRIPT = "script.shield_remote"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteCommandScript:
|
||||||
|
"""t47 B: one optional script for remote.send_command, replacing the key path.
|
||||||
|
|
||||||
|
A remote command is a SEQUENCE (repeats, long presses, delays), so the escape
|
||||||
|
hatch is one script for the whole call rather than a per-key override: an IR
|
||||||
|
bridge, a macro or a second device needs the call, not the keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _entity(self, modules, script=None):
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
served = []
|
||||||
|
entity.hass = SimpleNamespace(services=_services(served))
|
||||||
|
if script is not None:
|
||||||
|
entity._entry.options[CONF_REMOTE_COMMAND_SCRIPT] = script
|
||||||
|
return entity, served
|
||||||
|
|
||||||
|
async def test_a_configured_script_receives_the_exact_parameters(self, modules):
|
||||||
|
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||||
|
|
||||||
|
await entity.async_send_command(
|
||||||
|
["HOME", "KEYCODE_DPAD_CENTER"],
|
||||||
|
num_repeats=3,
|
||||||
|
delay_secs=0.25,
|
||||||
|
hold_secs=1.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert served == [
|
||||||
|
(
|
||||||
|
"script",
|
||||||
|
"turn_on",
|
||||||
|
{
|
||||||
|
"entity_id": WATCH_SCRIPT,
|
||||||
|
"variables": {
|
||||||
|
SCRIPT_VAR_COMMAND: ["HOME", "KEYCODE_DPAD_CENTER"],
|
||||||
|
SCRIPT_VAR_NUM_REPEATS: 3,
|
||||||
|
SCRIPT_VAR_DELAY_SECS: 0.25,
|
||||||
|
SCRIPT_VAR_HOLD_SECS: 1.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert entity._manager._protocol.key_calls == [] # key path untouched
|
||||||
|
|
||||||
|
async def test_the_service_defaults_are_named_too(self, modules):
|
||||||
|
"""The variable set is fixed: a script can rely on all four being present."""
|
||||||
|
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||||
|
|
||||||
|
await entity.async_send_command(["home"])
|
||||||
|
|
||||||
|
variables = served[0][2]["variables"]
|
||||||
|
assert variables == {
|
||||||
|
SCRIPT_VAR_COMMAND: ["home"],
|
||||||
|
SCRIPT_VAR_NUM_REPEATS: 1,
|
||||||
|
SCRIPT_VAR_DELAY_SECS: 0.0,
|
||||||
|
SCRIPT_VAR_HOLD_SECS: 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_unset_keeps_the_native_key_path(self, modules):
|
||||||
|
entity, served = self._entity(modules)
|
||||||
|
|
||||||
|
await entity.async_send_command(["HOME"], num_repeats=2, hold_secs=0.0)
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert entity._manager._protocol.key_calls == [
|
||||||
|
("KEYCODE_HOME", "SHORT"),
|
||||||
|
("KEYCODE_HOME", "SHORT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stored", ["", " ", None, 7])
|
||||||
|
async def test_a_blank_or_hand_edited_option_means_unset(self, modules, stored):
|
||||||
|
entity, served = self._entity(modules, stored)
|
||||||
|
|
||||||
|
await entity.async_send_command(["HOME"])
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert entity._manager._protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
|
||||||
|
|
||||||
|
async def test_a_raising_script_is_a_translated_error(self, modules):
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
|
||||||
|
entity, _served = self._entity(modules, WATCH_SCRIPT)
|
||||||
|
|
||||||
|
class _FailingServices:
|
||||||
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
|
raise RuntimeError("IR bridge is offline")
|
||||||
|
|
||||||
|
entity.hass.services = _FailingServices()
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError) as raised:
|
||||||
|
await entity.async_send_command(["HOME"])
|
||||||
|
|
||||||
|
assert raised.value.translation_domain == "nvidia_shield"
|
||||||
|
assert raised.value.translation_key == "command_script_failed"
|
||||||
|
assert raised.value.translation_placeholders == {
|
||||||
|
"action": "remote.send_command",
|
||||||
|
"script": WATCH_SCRIPT,
|
||||||
|
"error": "IR bridge is offline",
|
||||||
|
}
|
||||||
|
assert entity._manager._protocol.key_calls == [] # never a silent no-op
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_new_exception_keys_have_copy_in_both_files():
|
||||||
|
"""A raised translation_key with no copy shows the user a bare key."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||||
|
for name in ("strings.json", "translations/en.json"):
|
||||||
|
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
|
||||||
|
for key in ("unknown_source", "command_script_failed"):
|
||||||
|
assert exceptions[key]["title"], (name, key)
|
||||||
|
assert exceptions[key]["message"], (name, key)
|
||||||
|
assert "{source}" in json.loads(
|
||||||
|
(root / "strings.json").read_text(encoding="utf-8")
|
||||||
|
)["exceptions"]["unknown_source"]["message"]
|
||||||
|
assert "remote_command_script" in json.loads(
|
||||||
|
(root / "translations/en.json").read_text(encoding="utf-8")
|
||||||
|
)["options"]["step"]["init"]["data"]
|
||||||
@@ -11,14 +11,14 @@ import sys
|
|||||||
import types
|
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,
|
||||||
@@ -35,6 +35,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
# Loaded execution runs beneath the stubs; never escape this file either.
|
# Loaded execution runs beneath the stubs; never escape this file either.
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
@@ -68,6 +69,19 @@ def _install_import_stubs() -> 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):
|
||||||
return _form(step_id, errors or {})
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
|
description_placeholders=None):
|
||||||
|
# t56: the real MENU result shape; the flow manager dispatches the
|
||||||
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
|
return {
|
||||||
|
"type": "menu",
|
||||||
|
"step_id": step_id,
|
||||||
|
"menu_options": list(menu_options),
|
||||||
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
return {"type": "create_entry", "title": title, "data": data,
|
return {"type": "create_entry", "title": title, "data": data,
|
||||||
"options": options}
|
"options": options}
|
||||||
@@ -76,7 +90,30 @@ def _install_import_stubs() -> None:
|
|||||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
class _BaseOptionsFlow:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
config_entries.OptionsFlow = _BaseOptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
core = types.ModuleType("homeassistant.core")
|
core = types.ModuleType("homeassistant.core")
|
||||||
@@ -98,12 +135,12 @@ def _install_import_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -136,9 +173,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: "",
|
||||||
@@ -161,15 +197,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
|
||||||
@@ -194,6 +225,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
|||||||
mocked_test_connect.assert_awaited_once_with(
|
mocked_test_connect.assert_awaited_once_with(
|
||||||
"shield.local", 5555, _KEY_PATH
|
"shield.local", 5555, _KEY_PATH
|
||||||
)
|
)
|
||||||
|
# t56: the pairing hands over to the setup menu; Finish creates the entry
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
result = await flow.async_step_finish(None)
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -37,6 +38,7 @@ _STUB_NAMES = (
|
|||||||
"homeassistant.const",
|
"homeassistant.const",
|
||||||
"homeassistant.helpers",
|
"homeassistant.helpers",
|
||||||
"homeassistant.helpers.entity_registry",
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
"homeassistant.helpers.selector",
|
"homeassistant.helpers.selector",
|
||||||
"nsh.helpers",
|
"nsh.helpers",
|
||||||
"nsh.config_flow",
|
"nsh.config_flow",
|
||||||
@@ -108,6 +110,19 @@ def _install_stubs() -> 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):
|
||||||
return _form(step_id, errors or {})
|
return _form(step_id, errors or {})
|
||||||
|
|
||||||
|
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||||
|
description_placeholders=None):
|
||||||
|
# t56: the real MENU result shape; the flow manager dispatches the
|
||||||
|
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||||
|
return {
|
||||||
|
"type": "menu",
|
||||||
|
"step_id": step_id,
|
||||||
|
"menu_options": list(menu_options),
|
||||||
|
"description_placeholders": dict(
|
||||||
|
description_placeholders or {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def async_create_entry(self, *, title, data, options=None):
|
def async_create_entry(self, *, title, data, options=None):
|
||||||
return {"type": "create_entry", "title": title, "data": data,
|
return {"type": "create_entry", "title": title, "data": data,
|
||||||
"options": options}
|
"options": options}
|
||||||
@@ -132,9 +147,55 @@ def _install_stubs() -> None:
|
|||||||
|
|
||||||
class OptionsFlow:
|
class OptionsFlow:
|
||||||
pass
|
pass
|
||||||
|
# t29 G3b: the current options-flow API surface on the local stub
|
||||||
|
class _SuggestedMixin:
|
||||||
|
def add_suggested_values_to_schema(self, schema, suggested):
|
||||||
|
schema_dict = getattr(schema, "schema", None) or (
|
||||||
|
schema if isinstance(schema, dict) else {}
|
||||||
|
)
|
||||||
|
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||||
|
return schema
|
||||||
|
|
||||||
|
class OptionsFlowWithReload(_SuggestedMixin):
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||||
|
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||||
|
if entry_id is not None and scheduler is not None and hasattr(
|
||||||
|
scheduler, "async_schedule_reload"
|
||||||
|
):
|
||||||
|
scheduler.async_schedule_reload(entry_id)
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# t29 G3a: the current config-entries API surface on the local stub
|
||||||
|
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||||
|
|
||||||
|
def _update_reload_and_abort(self, entry, **kwargs):
|
||||||
|
# `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
|
||||||
|
for key, value in updates.items():
|
||||||
|
options[key] = value
|
||||||
|
if kwargs.get("unique_id"):
|
||||||
|
entry.unique_id = kwargs["unique_id"]
|
||||||
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
|
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
|
||||||
|
|
||||||
config_entries.ConfigFlow = ConfigFlow
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
config_entries.OptionsFlow = OptionsFlow
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
sys.modules["homeassistant.config_entries"] = config_entries
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
@@ -167,12 +228,12 @@ def _install_stubs() -> None:
|
|||||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
sys.modules["homeassistant.helpers"] = helpers
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||||
|
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -227,15 +288,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):
|
||||||
@@ -245,17 +301,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
|
||||||
|
|
||||||
@@ -297,9 +351,13 @@ class TestProtocolPairing:
|
|||||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||||
assert flow._unique_id == FAKE_MAC
|
assert flow._unique_id == FAKE_MAC
|
||||||
assert client.disconnected == 1
|
assert client.disconnected == 1
|
||||||
|
# t56: a finished pairing hands over to the setup menu; its Finish row
|
||||||
|
# creates the entry
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
result = await flow.async_step_finish(None)
|
||||||
assert result["type"] == "create_entry"
|
assert result["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()
|
||||||
@@ -313,6 +371,8 @@ class TestProtocolPairing:
|
|||||||
result = await flow.async_step_protocol_pair(
|
result = await flow.async_step_protocol_pair(
|
||||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||||
)
|
)
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
result = await flow.async_step_finish(None)
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||||
|
|
||||||
@@ -323,7 +383,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
|
||||||
@@ -345,12 +405,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",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -360,26 +433,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
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
"""t56: the config flow against a REAL Home Assistant install.
|
||||||
|
|
||||||
|
The clearing semantics the live bug turned on ("a configured script cannot be
|
||||||
|
removed") cannot be established from hand-written stubs: what matters is what
|
||||||
|
Home Assistant itself does when a selector is cleared and how it serialises the
|
||||||
|
form it hands the browser. So this file drives the REAL
|
||||||
|
``homeassistant.data_entry_flow.FlowManager`` with the REAL integration, real
|
||||||
|
``probatio`` validation and the real frontend serialiser
|
||||||
|
(``probatio.to_field_list`` + ``cv.custom_serializer``) — the exact call the
|
||||||
|
HTTP flow view makes.
|
||||||
|
|
||||||
|
Skips cleanly when homeassistant is not installed (every other test file runs
|
||||||
|
against stubs). Run it with:
|
||||||
|
|
||||||
|
uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py -q
|
||||||
|
|
||||||
|
What this file CANNOT prove: there is no browser here, so nothing about how a
|
||||||
|
form LOOKS. What it proves is the wire contract — the field list the frontend
|
||||||
|
receives and the exact ``user_input`` a submission produces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
pytest.importorskip(
|
||||||
|
"homeassistant",
|
||||||
|
reason="the real FlowManager is the point of this file",
|
||||||
|
)
|
||||||
|
|
||||||
|
probatio = pytest.importorskip("probatio")
|
||||||
|
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
import sys # noqa: E402
|
||||||
|
from pathlib import Path # noqa: E402
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ( # noqa: E402
|
||||||
|
SOURCE_USER,
|
||||||
|
ConfigEntries,
|
||||||
|
ConfigEntry,
|
||||||
|
OptionsFlowManager,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant # noqa: E402
|
||||||
|
from homeassistant.data_entry_flow import FlowManager # noqa: E402
|
||||||
|
from homeassistant.helpers import config_validation as cv # noqa: E402
|
||||||
|
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
||||||
|
from homeassistant.helpers import entity_registry as er # noqa: E402
|
||||||
|
from homeassistant.helpers.entity_registry import ( # noqa: E402
|
||||||
|
EntityRegistryItems,
|
||||||
|
EntityRegistrySettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the integration by PATH under its real package name: the repo is not an
|
||||||
|
# installed distribution, and the dotted import would need custom_components/ on
|
||||||
|
# sys.path plus every one of its siblings present.
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
|
cf = importlib.import_module("custom_components.nvidia_shield.config_flow")
|
||||||
|
|
||||||
|
SCRIPTS = {"turn_on": "script.shield_wake", "volume_set": "script.shield_volume"}
|
||||||
|
OPTIONS = {
|
||||||
|
"app_name_map": {"ar.tvplayer.tv": "TiviMate"},
|
||||||
|
"command_scripts": dict(SCRIPTS),
|
||||||
|
"host": "10.0.0.9",
|
||||||
|
"adb_enabled": True,
|
||||||
|
}
|
||||||
|
SCRIPT_COMMANDS = (
|
||||||
|
"turn_on",
|
||||||
|
"turn_off",
|
||||||
|
"volume_up",
|
||||||
|
"volume_down",
|
||||||
|
"volume_set",
|
||||||
|
"volume_mute",
|
||||||
|
"select_source",
|
||||||
|
)
|
||||||
|
SCRIPT_FIELD_PREFIX = "script_"
|
||||||
|
|
||||||
|
|
||||||
|
def field_list(schema):
|
||||||
|
"""EXACTLY what the HTTP flow view serialises for the browser."""
|
||||||
|
if schema is None:
|
||||||
|
return []
|
||||||
|
return probatio.to_field_list(schema, custom_serializer=cv.custom_serializer)
|
||||||
|
|
||||||
|
|
||||||
|
def field_names(schema):
|
||||||
|
return [field["name"] for field in field_list(schema)]
|
||||||
|
|
||||||
|
|
||||||
|
def suggested(schema):
|
||||||
|
return {
|
||||||
|
field["name"]: (field.get("description") or {}).get("suggested_value")
|
||||||
|
for field in field_list(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Entities the probe refers to. The real EntitySelector validates an id
|
||||||
|
#: against the entity registry, so these have to exist for a set (as opposed to
|
||||||
|
#: a clear) to be a legal submission.
|
||||||
|
ENTITIES = (
|
||||||
|
"script.shield_wake",
|
||||||
|
"script.shield_volume",
|
||||||
|
"script.kept",
|
||||||
|
"script.wake",
|
||||||
|
"media_player.jellyfin",
|
||||||
|
"switch.soundbar",
|
||||||
|
"switch.plug",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(name="ha")
|
||||||
|
async def ha_fixture(tmp_path):
|
||||||
|
"""A real HomeAssistant with just enough machinery to submit real forms.
|
||||||
|
|
||||||
|
HomeAssistant binds the running loop in __init__, so this has to be an async
|
||||||
|
fixture — which is also why the tests below are coroutines.
|
||||||
|
"""
|
||||||
|
hass = HomeAssistant(str(tmp_path))
|
||||||
|
# the real manager, exactly as core builds it — minus the reload SCHEDULE:
|
||||||
|
# core would immediately set the domain up, which needs the integration
|
||||||
|
# loader and the whole component bootstrap this probe deliberately skips.
|
||||||
|
hass.config_entries = ConfigEntries(hass, {})
|
||||||
|
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||||
|
# the real registry, populated with the entities the probe uses: a real
|
||||||
|
# EntitySelector refuses an id the registry does not know
|
||||||
|
device_registry.async_setup(hass)
|
||||||
|
await device_registry.async_load(hass)
|
||||||
|
# EntityRegistry's own loader reaches deep into core (device migration,
|
||||||
|
# restore, store), so the two containers it fills are set up directly —
|
||||||
|
# same objects, no core bootstrap.
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
registry.entities = EntityRegistryItems(hass)
|
||||||
|
registry.deleted_entities = {}
|
||||||
|
registry.settings = EntityRegistrySettings()
|
||||||
|
registry._entities_data = registry.entities.data
|
||||||
|
hass.data[er.DATA_REGISTRY] = registry
|
||||||
|
for entity_id in ENTITIES:
|
||||||
|
domain, object_id = entity_id.split(".", 1)
|
||||||
|
registry.async_get_or_create(
|
||||||
|
domain, "test", entity_id, suggested_object_id=object_id
|
||||||
|
)
|
||||||
|
assert registry.async_get("script.kept") is not None
|
||||||
|
return hass
|
||||||
|
|
||||||
|
|
||||||
|
def make_entry(hass, options=None):
|
||||||
|
entry = ConfigEntry(
|
||||||
|
version=1,
|
||||||
|
minor_version=1,
|
||||||
|
domain="nvidia_shield",
|
||||||
|
title=f"Shield {len(hass.config_entries._entries) + 1}",
|
||||||
|
data={},
|
||||||
|
options=dict(options if options is not None else OPTIONS),
|
||||||
|
source=SOURCE_USER,
|
||||||
|
unique_id=None,
|
||||||
|
subentries_data=(),
|
||||||
|
discovery_keys={},
|
||||||
|
)
|
||||||
|
# the probe "saves" by writing back what create_entry returned, exactly as
|
||||||
|
# core's async_update_entry would; ConfigEntry exposes options through a
|
||||||
|
# read-only property, so the private store is written instead
|
||||||
|
object.__setattr__(entry, "_options", dict(entry.options))
|
||||||
|
hass.config_entries._entries[entry.entry_id] = entry
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SetupManager(FlowManager):
|
||||||
|
"""A real FlowManager that builds the integration's real config flow.
|
||||||
|
|
||||||
|
Overrides only the handler construction, and does there what core does at
|
||||||
|
config_entries.py:1959 — the flow's ``init_step`` is the SOURCE, which is
|
||||||
|
why a real config flow starts at ``async_step_user`` and not at ``init``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||||
|
flow = cf.NvidiaShieldConfigFlow()
|
||||||
|
flow.init_step = (context or {}).get("source", SOURCE_USER)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
async def async_finish_flow(self, flow, result):
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def setup_manager(hass):
|
||||||
|
return SetupManager(hass)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_setup(hass):
|
||||||
|
"""Start a real user-initiated setup flow and return (manager, result)."""
|
||||||
|
manager = setup_manager(hass)
|
||||||
|
result = await manager.async_init(
|
||||||
|
"nvidia_shield", context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
return manager, result
|
||||||
|
|
||||||
|
|
||||||
|
#: The first setup form, as the real frontend submits it: EVERY rendered field.
|
||||||
|
def first_form(**overrides):
|
||||||
|
payload = {
|
||||||
|
"name": "Shield",
|
||||||
|
"jellyfin_entity": "",
|
||||||
|
"receiver_entity": "",
|
||||||
|
"receiver_input": "",
|
||||||
|
"power_entity": "",
|
||||||
|
"adb_enabled": False,
|
||||||
|
"adb_store_app_info": True,
|
||||||
|
"host": "10.0.0.9",
|
||||||
|
"adb_port": 5555,
|
||||||
|
"protocol_enabled": False,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsManager(OptionsFlowManager):
|
||||||
|
"""OptionsFlowManager that builds the integration's real options flow.
|
||||||
|
|
||||||
|
Overrides ONLY the handler lookup: real HA resolves it through
|
||||||
|
``async_get_options_flow`` from the registered integration, which needs a
|
||||||
|
fully loaded core. Everything else — menu dispatch, schema validation,
|
||||||
|
``next_step_id`` routing — is the real implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||||
|
return cf.NvidiaShieldOptionsFlow()
|
||||||
|
|
||||||
|
|
||||||
|
def options_manager(hass):
|
||||||
|
return OptionsManager(hass)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_options(hass, entry):
|
||||||
|
manager = options_manager(hass)
|
||||||
|
result = await manager.async_init(
|
||||||
|
entry.entry_id,
|
||||||
|
context={"source": SOURCE_USER, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
return manager, result
|
||||||
|
|
||||||
|
|
||||||
|
def full_script_page(value=""):
|
||||||
|
"""A real submission carries EVERY rendered field — this is that page."""
|
||||||
|
return {f"{SCRIPT_FIELD_PREFIX}{c}": value for c in SCRIPT_COMMANDS}
|
||||||
|
|
||||||
|
|
||||||
|
async def open_scripts(hass, entry):
|
||||||
|
"""Open the Scripts sub-screen through the real menu."""
|
||||||
|
manager, menu = await open_options(hass, entry)
|
||||||
|
scripts = await manager.async_configure(menu["flow_id"], {"next_step_id": "scripts"})
|
||||||
|
assert scripts["type"] == "form", scripts
|
||||||
|
return manager, scripts
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealMenuFlow:
|
||||||
|
"""The options page is a real menu with real sub-screens."""
|
||||||
|
|
||||||
|
async def test_the_page_is_a_menu_and_every_row_opens(self, ha):
|
||||||
|
entry = make_entry(ha)
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
|
||||||
|
assert menu["type"] == "menu"
|
||||||
|
assert menu["step_id"] == "init"
|
||||||
|
assert set(menu["menu_options"]) == {
|
||||||
|
"connection",
|
||||||
|
"receiver",
|
||||||
|
"apps",
|
||||||
|
"discover",
|
||||||
|
"scripts",
|
||||||
|
"finish",
|
||||||
|
}
|
||||||
|
# HA renders the rows from the flow result, and validates the choice
|
||||||
|
# against the menu's own schema
|
||||||
|
assert field_names(menu["data_schema"]) == ["next_step_id"]
|
||||||
|
|
||||||
|
# each row is opened on its own flow, because a real flow keeps ONE
|
||||||
|
# step at a time — reopening from a fresh menu is what the browser does
|
||||||
|
for row in menu["menu_options"]:
|
||||||
|
if row in ("finish", "discover"):
|
||||||
|
# actions: `finish` commits and `discover` sweeps, neither renders
|
||||||
|
continue
|
||||||
|
_manager, fresh = await open_options(ha, entry)
|
||||||
|
opened = await _manager.async_configure(
|
||||||
|
fresh["flow_id"], {"next_step_id": row}
|
||||||
|
)
|
||||||
|
assert opened["type"] == "form", row
|
||||||
|
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
||||||
|
# step_id must be the menu row id (the method name)
|
||||||
|
assert opened["step_id"] == row
|
||||||
|
|
||||||
|
async def test_an_unknown_menu_row_is_rejected_by_the_real_schema(self, ha):
|
||||||
|
entry = make_entry(ha)
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
|
||||||
|
from homeassistant.data_entry_flow import InvalidData
|
||||||
|
|
||||||
|
# real HA validates the choice against the menu schema (probatio.In on
|
||||||
|
# the offered ids) before the flow is even dispatched
|
||||||
|
with pytest.raises(InvalidData):
|
||||||
|
await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "not_a_step"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealAppsField:
|
||||||
|
"""[object Object]: the field list must carry TEXT, never a dict."""
|
||||||
|
|
||||||
|
async def test_the_apps_suggestion_is_the_serialized_text(self, ha):
|
||||||
|
entry = make_entry(ha)
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||||
|
|
||||||
|
suggested_values = suggested(apps["data_schema"])
|
||||||
|
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
||||||
|
assert isinstance(suggested_values["app_name_map"], str)
|
||||||
|
|
||||||
|
# and nothing in the whole field list carries a dict for a text field
|
||||||
|
for field in field_list(apps["data_schema"]):
|
||||||
|
value = (field.get("description") or {}).get("suggested_value")
|
||||||
|
if field.get("type") == "string":
|
||||||
|
assert not isinstance(value, (dict, list)), field["name"]
|
||||||
|
|
||||||
|
async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha):
|
||||||
|
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||||
|
|
||||||
|
fields = field_list(apps["data_schema"])
|
||||||
|
assert [f["name"] for f in fields] == ["app_name_map"]
|
||||||
|
assert (fields[0].get("description") or {}).get("suggested_value") in (None, "")
|
||||||
|
|
||||||
|
async def test_a_table_row_round_trips_through_the_real_flow(self, ha):
|
||||||
|
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||||
|
|
||||||
|
saved = await manager.async_configure(
|
||||||
|
apps["flow_id"],
|
||||||
|
{"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"},
|
||||||
|
)
|
||||||
|
assert saved["type"] == "menu" # back at the menu, nothing committed yet
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
saved["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||||
|
assert created["data"]["app_icon_map"] == {
|
||||||
|
"com.plex.hdx": "http://icons.local/plex.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealScriptClearing:
|
||||||
|
"""The live bug: what the REAL flow stores when a selector is cleared."""
|
||||||
|
|
||||||
|
async def test_set_then_cleared_persists_through_save_and_reopen(self, ha):
|
||||||
|
"""t56 unit 0 / t57 F4: SET, CLEAR, SAVE, REOPEN — the live symptom.
|
||||||
|
|
||||||
|
The whole page goes through the real flow manager: first with a script
|
||||||
|
SELECTED (an entity id, which the seeded registry resolves), then with
|
||||||
|
that one selector cleared while the other keeps its value. Both halves
|
||||||
|
are driven here — the earlier version of this file claimed entity ids
|
||||||
|
could not be driven, which was simply false (the receiver page in this
|
||||||
|
file has always submitted ``switch.soundbar``).
|
||||||
|
"""
|
||||||
|
entry = make_entry(ha)
|
||||||
|
manager, scripts = await open_scripts(ha, entry)
|
||||||
|
|
||||||
|
# 1. SET: two commands selected through the real flow, from real ids
|
||||||
|
page = full_script_page("")
|
||||||
|
page["script_turn_on"] = "script.kept"
|
||||||
|
page["script_volume_set"] = SCRIPTS["volume_set"]
|
||||||
|
after_set = await manager.async_configure(scripts["flow_id"], page)
|
||||||
|
assert after_set["type"] == "menu", after_set
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
after_set["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["data"]["command_scripts"] == {
|
||||||
|
"turn_on": "script.kept",
|
||||||
|
"volume_set": SCRIPTS["volume_set"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. CLEAR: the same page with ONE selector emptied (a cleared selector
|
||||||
|
# arrives as ''), the other left set
|
||||||
|
saved = dict(entry.options)
|
||||||
|
saved.update(created["data"])
|
||||||
|
object.__setattr__(entry, "_options", saved)
|
||||||
|
manager2, scripts2 = await open_scripts(ha, entry)
|
||||||
|
assert suggested(scripts2["data_schema"])["script_turn_on"] == "script.kept"
|
||||||
|
|
||||||
|
cleared = full_script_page("")
|
||||||
|
cleared["script_turn_on"] = ""
|
||||||
|
cleared["script_volume_set"] = SCRIPTS["volume_set"]
|
||||||
|
after_clear = await manager2.async_configure(scripts2["flow_id"], cleared)
|
||||||
|
assert after_clear["type"] == "menu", after_clear
|
||||||
|
created2 = await manager2.async_configure(
|
||||||
|
after_clear["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created2["data"]["command_scripts"] == {
|
||||||
|
"volume_set": SCRIPTS["volume_set"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. PERSIST: re-open over the SAVED options — the cleared command must
|
||||||
|
# not come back, and no flat script_* key was ever stored
|
||||||
|
saved2 = dict(entry.options)
|
||||||
|
saved2.update(created2["data"])
|
||||||
|
object.__setattr__(entry, "_options", saved2)
|
||||||
|
assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)]
|
||||||
|
manager3, scripts3 = await open_scripts(ha, entry)
|
||||||
|
values = suggested(scripts3["data_schema"])
|
||||||
|
assert values["script_volume_set"] == SCRIPTS["volume_set"]
|
||||||
|
assert values.get("script_turn_on") in (None, "")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("shape", ["empty_string", "null"])
|
||||||
|
async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape):
|
||||||
|
"""Real HA rejects '' / None for a plain EntitySelector; ours tolerates it."""
|
||||||
|
entry = make_entry(ha)
|
||||||
|
manager, scripts = await open_scripts(ha, entry)
|
||||||
|
|
||||||
|
page = full_script_page("")
|
||||||
|
page["script_turn_on"] = "" if shape == "empty_string" else None
|
||||||
|
saved = await manager.async_configure(scripts["flow_id"], page)
|
||||||
|
assert saved["type"] == "menu", saved
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
saved["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["data"]["command_scripts"] == {}
|
||||||
|
|
||||||
|
async def test_a_never_set_field_stays_out_of_the_dict(self, ha):
|
||||||
|
entry = make_entry(ha, options={"host": "10.0.0.9", "adb_enabled": True})
|
||||||
|
manager, scripts = await open_scripts(ha, entry)
|
||||||
|
|
||||||
|
# a real submission carries every rendered field, all empty here
|
||||||
|
saved = await manager.async_configure(scripts["flow_id"], full_script_page(""))
|
||||||
|
created = await manager.async_configure(
|
||||||
|
saved["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["data"]["command_scripts"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealSelectorValidation:
|
||||||
|
"""The selector distinction, pinned on the selector classes themselves.
|
||||||
|
|
||||||
|
``TestRealScriptClearing`` drives a cleared page end to end; this class pins
|
||||||
|
the underlying difference in isolation, so a regression says WHICH rule broke:
|
||||||
|
the built-in EntitySelector rejects a cleared field, ours accepts it and
|
||||||
|
still validates a real id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_real_entity_selector_rejects_a_cleared_field(self):
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
EntitySelector,
|
||||||
|
EntitySelectorConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
strict = EntitySelector(EntitySelectorConfig(domain="script"))
|
||||||
|
for cleared in ("", None):
|
||||||
|
with pytest.raises(probatio.Invalid):
|
||||||
|
strict(cleared)
|
||||||
|
|
||||||
|
def test_the_clearing_selector_accepts_cleared_and_real_values(self):
|
||||||
|
from custom_components.nvidia_shield.config_flow import _build_selectors
|
||||||
|
|
||||||
|
selector = _build_selectors()["script"]
|
||||||
|
assert selector("") == ""
|
||||||
|
assert selector(None) == ""
|
||||||
|
assert selector(" ") == ""
|
||||||
|
assert selector("script.whatever") == "script.whatever"
|
||||||
|
|
||||||
|
class TestRealNoLostEdits:
|
||||||
|
"""Edits made on one sub-screen survive a submission on another."""
|
||||||
|
|
||||||
|
async def test_connection_then_scripts_then_apps_all_survive(self, ha):
|
||||||
|
entry = make_entry(ha, options={})
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
|
||||||
|
connection = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "connection"}
|
||||||
|
)
|
||||||
|
after_connection = await manager.async_configure(
|
||||||
|
connection["flow_id"],
|
||||||
|
{
|
||||||
|
"host": "10.0.0.44",
|
||||||
|
"adb_port": 5555,
|
||||||
|
"adb_enabled": True,
|
||||||
|
"protocol_enabled": False,
|
||||||
|
"jellyfin_entity": "media_player.jellyfin",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert after_connection["type"] == "menu"
|
||||||
|
|
||||||
|
scripts = await manager.async_configure(
|
||||||
|
after_connection["flow_id"], {"next_step_id": "scripts"}
|
||||||
|
)
|
||||||
|
page = full_script_page("")
|
||||||
|
page["script_turn_on"] = "script.wake"
|
||||||
|
after_scripts = await manager.async_configure(scripts["flow_id"], page)
|
||||||
|
assert after_scripts["type"] == "menu"
|
||||||
|
|
||||||
|
apps = await manager.async_configure(
|
||||||
|
after_scripts["flow_id"], {"next_step_id": "apps"}
|
||||||
|
)
|
||||||
|
after_apps = await manager.async_configure(
|
||||||
|
apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||||
|
)
|
||||||
|
assert after_apps["type"] == "menu"
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
after_apps["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
data = created["data"]
|
||||||
|
# every earlier sub-screen's edit is still there
|
||||||
|
assert data["host"] == "10.0.0.44"
|
||||||
|
assert data["jellyfin_entity"] == "media_player.jellyfin"
|
||||||
|
assert data["command_scripts"] == {"turn_on": "script.wake"}
|
||||||
|
assert data["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealReceiverSelector:
|
||||||
|
"""The Receiver field accepts a switch, not only a media_player."""
|
||||||
|
|
||||||
|
async def test_the_selector_offers_switch_domains(self, ha):
|
||||||
|
entry = make_entry(ha, options={})
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
receiver = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = {f["name"]: f for f in field_list(receiver["data_schema"])}
|
||||||
|
selector = fields["receiver_entity"]["selector"]["entity"]
|
||||||
|
assert "switch" in selector["domain"]
|
||||||
|
assert "media_player" in selector["domain"]
|
||||||
|
|
||||||
|
async def test_a_switch_is_accepted_and_stored(self, ha):
|
||||||
|
entry = make_entry(ha, options={})
|
||||||
|
manager, menu = await open_options(ha, entry)
|
||||||
|
receiver = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = await manager.async_configure(
|
||||||
|
receiver["flow_id"],
|
||||||
|
{"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 2"},
|
||||||
|
)
|
||||||
|
created = await manager.async_configure(
|
||||||
|
saved["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["data"]["receiver_entity"] == "switch.soundbar"
|
||||||
|
assert created["data"]["receiver_input"] == "HDMI 2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealSetupMenu:
|
||||||
|
"""t57 UNIT 0a / F2: the SETUP flow under a REAL FlowManager.
|
||||||
|
|
||||||
|
The stub suite drove `async_step_menu` directly, which is exactly how a
|
||||||
|
broken menu stays green: it never asked Home Assistant to dispatch a row.
|
||||||
|
These tests do, for every row, and they are the red proof for the TypeError
|
||||||
|
the first form used to hit on the way to the menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _no_connect(result=True):
|
||||||
|
async def _connect(*args, **kwargs):
|
||||||
|
return result
|
||||||
|
|
||||||
|
return staticmethod(_connect)
|
||||||
|
|
||||||
|
async def test_the_first_form_leads_into_pairing_then_the_menu(self, ha, monkeypatch):
|
||||||
|
"""The whole ordinary path: first form → ADB pairing → the menu."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
assert form["type"] == "form"
|
||||||
|
assert form["step_id"] == "user"
|
||||||
|
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
assert pairing["type"] == "form"
|
||||||
|
assert pairing["step_id"] == "adb_pair"
|
||||||
|
|
||||||
|
result = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
assert result["type"] == "menu", result
|
||||||
|
# ADB is paired, the protocol is off: only the two sub-screens + finish
|
||||||
|
assert list(result["menu_options"]) == ["receiver", "connection", "finish"]
|
||||||
|
|
||||||
|
async def test_the_adb_skip_reaches_the_menu(self, ha, monkeypatch):
|
||||||
|
"""The path that used to die: skip ADB pairing, then render the menu."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
assert pairing["step_id"] == "adb_pair"
|
||||||
|
|
||||||
|
result = await manager.async_configure(
|
||||||
|
pairing["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "menu", result
|
||||||
|
assert "adb_pair" in result["menu_options"] # not paired: still offered
|
||||||
|
|
||||||
|
async def test_adb_pairing_success_stops_offering_that_row(self, ha, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
paired = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
assert paired["type"] == "menu"
|
||||||
|
assert "adb_pair" not in paired["menu_options"] # F2: the guard
|
||||||
|
|
||||||
|
async def test_every_setup_menu_row_dispatches_and_renders(self, ha, monkeypatch):
|
||||||
|
"""Every row HA offers must have a handler AND render a form whose
|
||||||
|
step_id is that same row, or the page dead-ends on submit."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
# the protocol row builds the androidtvremote2 client; this test is about
|
||||||
|
# DISPATCH, and the pairing internals have their own stub coverage. The
|
||||||
|
# UNREACHABLE branch is a real code path needing no library, so the row
|
||||||
|
# renders its real form.
|
||||||
|
async def _unreachable(self):
|
||||||
|
return None, "protocol_unreachable"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||||
|
)
|
||||||
|
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
menu_result = await manager.async_configure(
|
||||||
|
form["flow_id"],
|
||||||
|
first_form(adb_enabled=True, protocol_enabled=True),
|
||||||
|
)
|
||||||
|
assert menu_result["type"] == "form" # the ADB pairing form comes first
|
||||||
|
|
||||||
|
# hand the ADB form a skip so the MENU renders with every row
|
||||||
|
menu_result = await manager.async_configure(
|
||||||
|
menu_result["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
assert menu_result["type"] == "menu", menu_result
|
||||||
|
rows = list(menu_result["menu_options"])
|
||||||
|
assert rows == ["receiver", "connection", "adb_pair", "protocol_pair", "finish"]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# each row on its own flow (a flow is at one step at a time) AND on
|
||||||
|
# the manager that owns it: a flow lives in its manager's progress
|
||||||
|
row_manager, fresh = await open_setup(ha)
|
||||||
|
pairing = await row_manager.async_configure(
|
||||||
|
fresh["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||||
|
)
|
||||||
|
menu = await row_manager.async_configure(
|
||||||
|
pairing["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
opened = await row_manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": row}
|
||||||
|
)
|
||||||
|
assert opened["type"] in ("form", "menu", "create_entry"), (row, opened)
|
||||||
|
if opened["type"] == "form":
|
||||||
|
assert opened["step_id"] == row, (row, opened["step_id"])
|
||||||
|
|
||||||
|
async def test_a_setup_sub_screen_submission_is_accepted(self, ha, monkeypatch):
|
||||||
|
"""A rendered sub-screen must be re-submittable (row id == step_id)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
assert menu["type"] == "menu"
|
||||||
|
|
||||||
|
opened = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
assert opened["type"] == "form"
|
||||||
|
assert opened["step_id"] == "receiver"
|
||||||
|
|
||||||
|
back = await manager.async_configure(
|
||||||
|
opened["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||||
|
)
|
||||||
|
assert back["type"] == "menu", back
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
back["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||||
|
|
||||||
|
async def test_the_full_menu_walk_merges_and_creates_one_entry(self, ha, monkeypatch):
|
||||||
|
"""connection + receiver + finish → ONE entry carrying both edits."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||||
|
|
||||||
|
connection = await manager.async_configure(
|
||||||
|
menu["flow_id"], {"next_step_id": "connection"}
|
||||||
|
)
|
||||||
|
assert connection["step_id"] == "connection"
|
||||||
|
menu2 = await manager.async_configure(
|
||||||
|
connection["flow_id"], {"host": "10.0.0.55", "adb_port": 5555,
|
||||||
|
"adb_enabled": True, "protocol_enabled": False}
|
||||||
|
)
|
||||||
|
assert menu2["type"] == "menu"
|
||||||
|
|
||||||
|
receiver = await manager.async_configure(
|
||||||
|
menu2["flow_id"], {"next_step_id": "receiver"}
|
||||||
|
)
|
||||||
|
menu3 = await manager.async_configure(
|
||||||
|
receiver["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||||
|
)
|
||||||
|
|
||||||
|
created = await manager.async_configure(
|
||||||
|
menu3["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
assert created["type"] == "create_entry"
|
||||||
|
assert created["options"]["host"] == "10.0.0.55"
|
||||||
|
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("adb_enabled", "protocol_enabled", "connect", "pairs_protocol"),
|
||||||
|
[
|
||||||
|
(True, False, False, False), # ADB skipped
|
||||||
|
(True, False, True, False), # ADB paired, protocol off
|
||||||
|
(True, True, False, False), # ADB skipped, protocol skipped
|
||||||
|
(True, True, True, False), # ADB paired, protocol skipped
|
||||||
|
(False, True, False, False), # protocol only
|
||||||
|
],
|
||||||
|
ids=["adb-skip", "adb-paired", "adb-skip+protocol-skip",
|
||||||
|
"pair-adb", "protocol-only"],
|
||||||
|
)
|
||||||
|
async def test_every_ordinary_path_reaches_create_entry(
|
||||||
|
self, ha, monkeypatch, adb_enabled, protocol_enabled, connect, pairs_protocol
|
||||||
|
):
|
||||||
|
"""t57 UNIT 0a: the path map.
|
||||||
|
|
||||||
|
Under the pre-fix tree FOUR of these five died with
|
||||||
|
`TypeError: async_show_menu() got an unexpected keyword argument
|
||||||
|
'errors'` the moment the ADB pairing form was submitted or skipped —
|
||||||
|
so a new entry could not be created on any path that went near the
|
||||||
|
menu, and the sub-screens were unreachable. Each path is driven here to
|
||||||
|
`create_entry`.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(connect)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _unreachable(self):
|
||||||
|
return None, "protocol_unreachable"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
result = await manager.async_configure(
|
||||||
|
form["flow_id"],
|
||||||
|
first_form(adb_enabled=adb_enabled, protocol_enabled=protocol_enabled),
|
||||||
|
)
|
||||||
|
assert result["type"] == "form", result
|
||||||
|
|
||||||
|
for _ in range(12):
|
||||||
|
if result["type"] == "create_entry":
|
||||||
|
break
|
||||||
|
step = result.get("step_id")
|
||||||
|
if step == "adb_pair":
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"], {"skip_pairing": True}
|
||||||
|
)
|
||||||
|
elif step == "protocol_pair":
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"pairing_code": "1234", "host": "10.0.0.9", "skip_pairing": True},
|
||||||
|
)
|
||||||
|
elif result["type"] == "menu":
|
||||||
|
# the menu must always offer a way out
|
||||||
|
assert "finish" in result["menu_options"], result["menu_options"]
|
||||||
|
result = await manager.async_configure(
|
||||||
|
result["flow_id"], {"next_step_id": "finish"}
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - the assertion below reports it
|
||||||
|
break
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry", result
|
||||||
|
|
||||||
|
async def test_the_menu_description_reports_what_is_unpaired(self, ha, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||||
|
)
|
||||||
|
manager, form = await open_setup(ha)
|
||||||
|
pairing = await manager.async_configure(
|
||||||
|
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||||
|
)
|
||||||
|
menu = await manager.async_configure(pairing["flow_id"], {"skip_pairing": True})
|
||||||
|
|
||||||
|
assert menu["type"] == "menu"
|
||||||
|
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||||
|
assert "ADB" in note and "remote protocol" in note
|
||||||
File diff suppressed because it is too large.
Load diff
+104
-6
@@ -18,7 +18,7 @@ from nsh.connection import (
|
|||||||
ShieldConnectionManager,
|
ShieldConnectionManager,
|
||||||
StateStore,
|
StateStore,
|
||||||
)
|
)
|
||||||
from nsh.const import ADB_POLL_INTERVAL
|
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
|
||||||
|
|
||||||
|
|
||||||
def test_connection_module_depends_on_no_homeassistant():
|
def test_connection_module_depends_on_no_homeassistant():
|
||||||
@@ -177,11 +177,18 @@ class TestAppLabelCache:
|
|||||||
assert manager.app_label("com.unknown.app") is None
|
assert manager.app_label("com.unknown.app") is None
|
||||||
await manager._watchdog_cycle(5.0)
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
# still None (next tier), no crash — falls through cleanly
|
# the dump RAN and carried no label: definitive negative, and the
|
||||||
assert manager.app_label("com.unknown.app") is not None or True
|
# caller's next tier (the raw package) takes over — still None here,
|
||||||
|
# never a crash or a fabricated label
|
||||||
|
assert manager.app_label("com.unknown.app") is None
|
||||||
|
|
||||||
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
|
async def test_label_less_package_is_a_definitive_negative(self):
|
||||||
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
|
"""t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
|
||||||
|
|
||||||
|
The old code additionally stamped this answer on FAILURE paths; t55
|
||||||
|
separates the two (see the retry tests below), but the negative cache
|
||||||
|
for a dump that really ran stays — the round-trip tax must not return.
|
||||||
|
"""
|
||||||
manager, _t = self._manager_with_dump()
|
manager, _t = self._manager_with_dump()
|
||||||
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||||
|
|
||||||
@@ -201,6 +208,94 @@ class TestAppLabelCache:
|
|||||||
assert len(manager._poller.shell_calls) == first_dump_count
|
assert len(manager._poller.shell_calls) == first_dump_count
|
||||||
assert manager.app_label("com.unknown.app") is None # falls through
|
assert manager.app_label("com.unknown.app") is None # falls through
|
||||||
|
|
||||||
|
async def test_failed_fetch_is_not_cached_and_retries(self):
|
||||||
|
"""t55 root cause: a fetch that could NOT run must not become a miss.
|
||||||
|
|
||||||
|
The live defect was a metadata fetch failing and the failure being
|
||||||
|
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
|
||||||
|
which left app_name showing the raw package id with no way back. Here
|
||||||
|
the failed fetch stamps nothing, the next render re-enqueues, and the
|
||||||
|
retry resolves the real label.
|
||||||
|
"""
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
real_shell = manager._poller.shell
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
async def _flaky(cmd):
|
||||||
|
calls.append(cmd)
|
||||||
|
if "pm dump" in cmd:
|
||||||
|
raise RuntimeError("adb shell blew up")
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
manager._poller.shell = _flaky
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# the fetch ran and failed → NOTHING is stamped as an answer
|
||||||
|
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||||
|
assert [c for c in calls if "pm dump" in c]
|
||||||
|
|
||||||
|
# the next render enqueues again (the miss is retried, not silenced)
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None
|
||||||
|
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||||
|
|
||||||
|
# the retry reaches the device and resolves the label
|
||||||
|
manager._poller.shell = real_shell
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||||
|
|
||||||
|
async def test_shell_failure_is_not_cached_and_retries(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
|
||||||
|
|
||||||
|
async def _boom(_cmd):
|
||||||
|
raise RuntimeError("adb shell blew up")
|
||||||
|
|
||||||
|
manager._poller.shell = _boom
|
||||||
|
manager.app_label("ar.tvplayer.tv")
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||||
|
assert manager.app_label_requests == set()
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None
|
||||||
|
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
|
||||||
|
|
||||||
|
async def test_in_flight_fetch_is_not_enqueued_twice(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
|
||||||
|
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||||
|
assert manager.app_label_inflight == set()
|
||||||
|
|
||||||
|
async def test_one_fetch_per_cycle_even_under_a_burst(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
|
||||||
|
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
|
||||||
|
|
||||||
|
manager.app_label("com.a")
|
||||||
|
manager.app_label("com.b")
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
|
||||||
|
assert len(dumps) == 2 # one per cycle, both eventually resolved
|
||||||
|
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
]
|
||||||
|
|
||||||
async def test_pm_dump_command_is_shell_quoted(self):
|
async def test_pm_dump_command_is_shell_quoted(self):
|
||||||
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
||||||
import shlex
|
import shlex
|
||||||
@@ -215,8 +310,11 @@ class TestAppLabelCache:
|
|||||||
|
|
||||||
await asyncio.sleep(0) # drain the fetch task
|
await asyncio.sleep(0) # drain the fetch task
|
||||||
|
|
||||||
|
# t57: the dump carries the exit marker too, so a blank answer can be
|
||||||
|
# told from a FAILED fetch; the package is still quoted
|
||||||
assert manager._poller.shell_calls == [
|
assert manager._poller.shell_calls == [
|
||||||
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300"
|
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300; "
|
||||||
|
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
|
||||||
|
|
||||||
|
The stub suites drive entities through hand-written fakes and replace
|
||||||
|
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
||||||
|
perfectly healthy there while a live install raises
|
||||||
|
``TypeError: 'method' object is not iterable`` on every state write and the
|
||||||
|
entity never renders.
|
||||||
|
|
||||||
|
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
|
||||||
|
suite and the drift sweep share ONE implementation — and the CI drift job, which
|
||||||
|
runs against a real Home Assistant, gates the same code path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ha_api_drift
|
||||||
|
from conftest import _SHIELD_DIR
|
||||||
|
|
||||||
|
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
|
||||||
|
|
||||||
|
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
|
||||||
|
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
|
||||||
|
_PRE_FIX = f'''
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntity
|
||||||
|
|
||||||
|
|
||||||
|
class Player(MediaPlayerEntity):
|
||||||
|
{_PLAIN_DEF}
|
||||||
|
return {{"app_id": "x"}}
|
||||||
|
'''
|
||||||
|
|
||||||
|
_FIXED = f'''
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntity
|
||||||
|
|
||||||
|
|
||||||
|
class Player(MediaPlayerEntity):
|
||||||
|
@property
|
||||||
|
{_PLAIN_DEF}
|
||||||
|
return {{"app_id": "x"}}
|
||||||
|
'''
|
||||||
|
|
||||||
|
_INHERITED_BASE = '''
|
||||||
|
from homeassistant.components.sensor import SensorEntity
|
||||||
|
|
||||||
|
|
||||||
|
class Base(SensorEntity):
|
||||||
|
"""Our own intermediate base — subclasses are entities too."""
|
||||||
|
|
||||||
|
|
||||||
|
class Child(Base):
|
||||||
|
def native_value(self):
|
||||||
|
return "x"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def _findings(tmp_path, source: str):
|
||||||
|
(tmp_path / "module.py").write_text(source, encoding="utf-8")
|
||||||
|
return ha_api_drift.scan_entity_contracts(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
|
||||||
|
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
|
||||||
|
assert ha_api_drift.scan_entity_contracts() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_check_flags_the_pre_fix_shape(tmp_path):
|
||||||
|
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
|
||||||
|
|
||||||
|
findings = _findings(tmp_path, _PRE_FIX)
|
||||||
|
|
||||||
|
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
|
||||||
|
assert findings[0].lineno == expected_line
|
||||||
|
assert "Player.extra_state_attributes" in findings[0].detail
|
||||||
|
assert "@property" in findings[0].detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_check_accepts_the_property_form(tmp_path):
|
||||||
|
assert _findings(tmp_path, _FIXED) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
|
||||||
|
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
|
||||||
|
findings = _findings(tmp_path, _INHERITED_BASE)
|
||||||
|
|
||||||
|
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tree_classification_is_not_vacuous():
|
||||||
|
"""A classifier that sees no entities would pass every check above."""
|
||||||
|
classified: set[str] = set()
|
||||||
|
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||||
|
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"NvidiaShieldMediaPlayer",
|
||||||
|
"NvidiaShieldCurrentAppSensor",
|
||||||
|
"NvidiaShieldNowPlayingSensor",
|
||||||
|
"NvidiaShieldRemoteEntity",
|
||||||
|
"NvidiaShieldPairAdbButton",
|
||||||
|
} <= classified
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
|
||||||
|
"""One implementation: the sweep's report carries the contract pass too."""
|
||||||
|
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
|
||||||
|
|
||||||
|
report = ha_api_drift.scan(tmp_path)
|
||||||
|
kinds = [finding.kind for finding in report.findings]
|
||||||
|
|
||||||
|
# The contract pass runs FIRST (pure AST). What the API passes add after it
|
||||||
|
# depends on the interpreter: with homeassistant installed they judge the
|
||||||
|
# module's imports, without it the sweep says it cannot judge (both are
|
||||||
|
# covered here so the check holds in either variant).
|
||||||
|
assert kinds[0] == "entity-attribute-is-a-method"
|
||||||
|
rest = set(kinds[1:])
|
||||||
|
if ha_api_drift.homeassistant_available():
|
||||||
|
# the API passes ran and found nothing wrong with this module
|
||||||
|
assert rest == set(), rest
|
||||||
|
else:
|
||||||
|
assert "no-homeassistant" in rest
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_check_ignores_missing_paths_instead_of_raising():
|
||||||
|
"""scan() owns the bad-path finding; the pass just skips."""
|
||||||
|
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"""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"
|
||||||
|
),
|
||||||
|
# t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes
|
||||||
|
# no `errors` in HA 2026.10.1, and the integration passed one — so every
|
||||||
|
# ordinary setup path raised TypeError at the moment a user submitted the
|
||||||
|
# first form, which is invisible to a stub suite whose stubs accept it.
|
||||||
|
"t57_show_menu_errors.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
||||||
|
" async def async_step_user(self, user_input=None):\n"
|
||||||
|
" return self.async_show_menu(\n"
|
||||||
|
" step_id='menu',\n"
|
||||||
|
" menu_options=['finish'],\n"
|
||||||
|
" errors={\'base\': \'nope\'},\n"
|
||||||
|
" )\n"
|
||||||
|
),
|
||||||
|
"t35_options_updates.py": (
|
||||||
|
"from homeassistant import config_entries\n"
|
||||||
|
"\n"
|
||||||
|
"\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"),
|
||||||
|
("t57_show_menu_errors.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",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sensor",
|
||||||
|
"NvidiaShieldAppsSensor",
|
||||||
|
{"adb_enabled": True},
|
||||||
|
"sensor.shield_installed_apps",
|
||||||
|
),
|
||||||
|
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||||
|
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Classified as an entity by the sweep, but an abstract intermediate base HA
|
||||||
|
#: never instantiates — its concrete subclasses are in the matrix.
|
||||||
|
_INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX
|
||||||
|
)
|
||||||
|
def test_entities_calculate_state_under_real_homeassistant(
|
||||||
|
module_name, class_name, options, entity_id
|
||||||
|
):
|
||||||
|
"""t40 blocker: HA's own state write must not raise for ANY entity.
|
||||||
|
|
||||||
|
``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the
|
||||||
|
extra attributes with ``attr |= ...``; the pre-fix media player raised
|
||||||
|
``TypeError: 'method' object is not iterable`` here, so it could never render.
|
||||||
|
"""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
entity, states = _real_ha_entity(
|
||||||
|
module_name, class_name, options=options, entity_id=entity_id
|
||||||
|
)
|
||||||
|
|
||||||
|
written_id, state, attrs = _write_ha_state(entity, states)
|
||||||
|
|
||||||
|
assert written_id == entity_id
|
||||||
|
assert isinstance(state, str)
|
||||||
|
assert isinstance(attrs, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_media_player_extra_attributes_reach_the_state_machine():
|
||||||
|
"""The property's payload must survive HA's merge, not merely not raise."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
entity, states = _real_ha_entity(
|
||||||
|
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||||
|
)
|
||||||
|
|
||||||
|
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||||
|
|
||||||
|
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
|
||||||
|
"""A new entity class must join the matrix above, or this test fails.
|
||||||
|
|
||||||
|
The sweep classifies entities from the AST; this keeps the real-HA coverage
|
||||||
|
in step with that classification instead of silently shrinking.
|
||||||
|
"""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
classified: set[str] = set()
|
||||||
|
for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")):
|
||||||
|
classified |= ha_api_drift.entity_class_names(
|
||||||
|
ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
)
|
||||||
|
covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX}
|
||||||
|
|
||||||
|
uncovered = classified - covered - _INTERMEDIATE_BASES
|
||||||
|
|
||||||
|
assert uncovered == set(), (
|
||||||
|
f"entity classes never exercised against real HA: {sorted(uncovered)} — "
|
||||||
|
f"add them to _REAL_HA_MATRIX"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- t62: dead code and the floor
|
||||||
|
#
|
||||||
|
# Both defects pinned below shipped while every other gate was green:
|
||||||
|
# connection.py carried a duplicated copy of the label-drain body AFTER its
|
||||||
|
# `return cached` (valid Python, dead, invisible to this sweep), and the
|
||||||
|
# call-argument floor sat at 20 while the counts had moved to ~46, so deleting a
|
||||||
|
# flow-display API owner from HA_CALL_OWNERS did not trip it.
|
||||||
|
|
||||||
|
#: t62 F2: the shape that shipped — a bare docstring plus the whole drain left
|
||||||
|
#: after `return cached`, inside the same method. Line 6 is the dead statement.
|
||||||
|
UNREACHABLE_PRE_FIX = (
|
||||||
|
"class Sweep:\n"
|
||||||
|
" def labels(self, requests):\n"
|
||||||
|
" cached = {}\n"
|
||||||
|
" return cached\n"
|
||||||
|
"\n"
|
||||||
|
' """Start at most one label fetch per watchdog cycle."""\n'
|
||||||
|
" if not requests:\n"
|
||||||
|
" return\n"
|
||||||
|
" requests.discard(min(requests))\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_flags_the_pre_fix_unreachable_body(tmp_path):
|
||||||
|
"""t62 F2 red proof: the duplicated drain body after `return cached` is dead."""
|
||||||
|
(tmp_path / "dead_sweep.py").write_text(UNREACHABLE_PRE_FIX, encoding="utf-8")
|
||||||
|
|
||||||
|
findings = ha_api_drift.scan_unreachable(tmp_path)
|
||||||
|
|
||||||
|
assert [(finding.path, finding.kind) for finding in findings] == [
|
||||||
|
("dead_sweep.py", "unreachable-statement")
|
||||||
|
], findings
|
||||||
|
assert findings[0].lineno == 6 # the dead docstring, not the return
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_terminal_statement_kind_is_flagged(tmp_path):
|
||||||
|
"""`return`, `raise`, `continue` and `break` all end a block (t62 F2)."""
|
||||||
|
(tmp_path / "terminals.py").write_text(
|
||||||
|
"def a():\n"
|
||||||
|
" return 1\n"
|
||||||
|
" x = 1\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"def b():\n"
|
||||||
|
" raise SystemExit(1)\n"
|
||||||
|
" y = 2\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"def c(items):\n"
|
||||||
|
" for item in items:\n"
|
||||||
|
" continue\n"
|
||||||
|
" z = 3\n"
|
||||||
|
" while True:\n"
|
||||||
|
" break\n"
|
||||||
|
" w = 4\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = ha_api_drift.scan_unreachable(tmp_path)
|
||||||
|
|
||||||
|
assert [finding.kind for finding in findings] == ["unreachable-statement"] * 4, findings
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_production_tree_has_no_unreachable_statements():
|
||||||
|
"""The gate itself: dead code is a finding, so it cannot ship silently."""
|
||||||
|
assert ha_api_drift.scan_unreachable() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_unreachable_pass_walks_real_statement_blocks():
|
||||||
|
"""The floor proves "no findings" is not a pass that stopped parsing."""
|
||||||
|
report = ha_api_drift.scan()
|
||||||
|
|
||||||
|
assert report.statement_blocks >= ha_api_drift.FLOORS["statement_blocks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch):
|
||||||
|
"""t62: the floor must notice an API OWNER going away, not just a bad walk."""
|
||||||
|
_require_installed_homeassistant()
|
||||||
|
|
||||||
|
intact = ha_api_drift.scan()
|
||||||
|
assert intact.floor_violations() == []
|
||||||
|
|
||||||
|
reduced = dict(ha_api_drift.HA_CALL_OWNERS)
|
||||||
|
del reduced["async_show_menu"] # the t57 UNIT 0a owner: 6 checked arguments
|
||||||
|
monkeypatch.setattr(ha_api_drift, "HA_CALL_OWNERS", reduced)
|
||||||
|
|
||||||
|
fewer = ha_api_drift.scan()
|
||||||
|
|
||||||
|
assert fewer.call_args < intact.call_args
|
||||||
|
assert fewer.call_args < ha_api_drift.FLOORS["call_args"], (
|
||||||
|
f"removing async_show_menu left {fewer.call_args} checked arguments, "
|
||||||
|
f"still at or above the floor of {ha_api_drift.FLOORS['call_args']} — "
|
||||||
|
"the flow-display owners are not load-bearing"
|
||||||
|
)
|
||||||
|
assert any("call_args" in line for line in fewer.floor_violations()), (
|
||||||
|
fewer.floor_violations()
|
||||||
|
)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""tests/test_harness_scoping.py — the stub harness must not depend on file order (t44).
|
||||||
|
|
||||||
|
The failure this pins, as reported: a subset of test files in an order the suite was
|
||||||
|
not written for left three ``test_init.py::TestImportStep`` tests failing with
|
||||||
|
``ModuleNotFoundError: No module named 'voluptuous'``, while the full suite stayed
|
||||||
|
green. Two mechanisms produced it, and both are order-only:
|
||||||
|
|
||||||
|
1. ``entity_stublib.install()`` trusted a single sentinel
|
||||||
|
(``homeassistant.components.media_player``). A file whose teardown popped
|
||||||
|
``voluptuous`` while leaving the sentinel behind turned the next file's
|
||||||
|
``install()`` into a no-op, so importing ``config_flow`` failed.
|
||||||
|
2. A file's teardown restored only the names it had listed. A module it loaded
|
||||||
|
(``nsh.diagnostics``, ``nsh.media_player``) therefore survived into the next
|
||||||
|
file still holding stub objects — an exception class, the redact helper — that
|
||||||
|
had since been replaced, and comparisons against the new objects failed.
|
||||||
|
|
||||||
|
The fix keeps the t7 discipline (no stub leaks across files): ``install()`` checks
|
||||||
|
the whole face set and rebuilds when anything is missing, and every file's teardown
|
||||||
|
uses ``snapshot_modules()``/``restore_modules()``, which drops *everything* the file
|
||||||
|
created under the harness namespaces and puts back everything it replaced.
|
||||||
|
|
||||||
|
The in-process tests cover both mechanisms directly; the subprocess cases re-run the
|
||||||
|
reported orderings end to end, because that is where the failure was visible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TRACKED = ("homeassistant", "voluptuous", "nsh")
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped_faces() -> list[str]:
|
||||||
|
"""The HA/voluptuous faces every file scopes on (the nsh.* names are modules)."""
|
||||||
|
return [name for name in entity_stublib.ENTITIES_STUB_NAMES if not name.startswith("nsh.")]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*paths: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
*paths,
|
||||||
|
"-q",
|
||||||
|
"--no-header",
|
||||||
|
"-p",
|
||||||
|
"no:cacheprovider",
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStubFaceGuard:
|
||||||
|
"""Mechanism 1: a partially popped face set must be repaired, not trusted."""
|
||||||
|
|
||||||
|
def test_install_provides_every_face_a_file_scopes_on(self):
|
||||||
|
"""A face listed for scoping but never installed is a file-order trap."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
try:
|
||||||
|
entity_stublib.install()
|
||||||
|
|
||||||
|
missing = [name for name in _scoped_faces() if name not in sys.modules]
|
||||||
|
|
||||||
|
assert missing == []
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
def test_a_complete_face_set_is_left_alone(self):
|
||||||
|
"""The guard must still be an idempotence guard, not a rebuild."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
try:
|
||||||
|
entity_stublib.install()
|
||||||
|
before = {name: sys.modules[name] for name in _scoped_faces()}
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
|
||||||
|
assert {name: sys.modules[name] for name in _scoped_faces()} == before
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
def test_a_sentinel_left_behind_does_not_hide_a_missing_face(self):
|
||||||
|
"""The reported poisoning: media_player present, voluptuous gone."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
try:
|
||||||
|
entity_stublib.install()
|
||||||
|
kept = sys.modules["homeassistant.components.media_player"]
|
||||||
|
del sys.modules["voluptuous"]
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
|
||||||
|
assert "voluptuous" in sys.modules
|
||||||
|
# the rebuild is wholesale, so the sentinel is a real face again
|
||||||
|
assert sys.modules["homeassistant.components.media_player"] is not None
|
||||||
|
assert kept is not None
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeardownLeaks:
|
||||||
|
"""Mechanism 2: nothing a file created or replaced may survive its teardown."""
|
||||||
|
|
||||||
|
def test_teardown_removes_the_modules_a_file_loaded(self):
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
try:
|
||||||
|
entity_stublib.install()
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
created = [
|
||||||
|
name
|
||||||
|
for name in sys.modules
|
||||||
|
if name.startswith("nsh.") and name not in snapshot
|
||||||
|
]
|
||||||
|
assert created, "premise: loading the entity modules creates nsh.* modules"
|
||||||
|
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
assert [name for name in sys.modules if name.startswith("nsh.")] == [
|
||||||
|
name for name in snapshot if name.startswith("nsh.")
|
||||||
|
]
|
||||||
|
assert {
|
||||||
|
name: sys.modules.get(name)
|
||||||
|
for name in snapshot
|
||||||
|
if name.split(".", 1)[0] in TRACKED
|
||||||
|
} == snapshot
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
def test_a_reloaded_module_holds_the_current_stub_objects(self):
|
||||||
|
"""The stale-object failure, exactly: diagnostics vs the redact helper."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
try:
|
||||||
|
entity_stublib.install()
|
||||||
|
stale_module = entity_stublib.load_entity_modules()["diagnostics"]
|
||||||
|
stale = stale_module.async_redact_data
|
||||||
|
entity_stublib.restore_modules(snapshot) # ...the file ends
|
||||||
|
|
||||||
|
# ...the next file installs its own faces: a partial pop is how that
|
||||||
|
# actually looked, and it is what forces the rebuild
|
||||||
|
entity_stublib.install()
|
||||||
|
del sys.modules["homeassistant.helpers.redact"]
|
||||||
|
entity_stublib.install()
|
||||||
|
fresh_modules = entity_stublib.load_entity_modules()
|
||||||
|
|
||||||
|
current = sys.modules["homeassistant.helpers.redact"].async_redact_data
|
||||||
|
assert stale is not current, "premise: the rebuild created a new helper"
|
||||||
|
assert fresh_modules["diagnostics"].async_redact_data is current
|
||||||
|
# ...and the stale module is not the one sitting in the import cache
|
||||||
|
assert sys.modules["nsh.diagnostics"] is fresh_modules["diagnostics"]
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_file_that_loads_the_harness_scopes_it():
|
||||||
|
"""A missing teardown is invisible until the NEXT file runs — so check for it.
|
||||||
|
|
||||||
|
This is the mistake the fix itself invites: a new test file calls
|
||||||
|
load_entity_modules() for convenience and forgets the per-file scope, and the
|
||||||
|
failure surfaces somewhere else entirely (t44 added exactly that file).
|
||||||
|
"""
|
||||||
|
#: Files that exercise the harness in a CHILD process: they leave nothing in
|
||||||
|
#: this process's sys.modules, so they need no teardown.
|
||||||
|
child_process_only = {"test_module_coverage.py"}
|
||||||
|
offenders = []
|
||||||
|
for path in sorted((REPO_ROOT / "tests").glob("test_*.py")):
|
||||||
|
if path.name in child_process_only:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
loads = "load_entity_modules(" in text or "entity_stublib.install()" in text
|
||||||
|
if loads and "snapshot_modules()" not in text:
|
||||||
|
offenders.append(path.name)
|
||||||
|
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"paths",
|
||||||
|
[
|
||||||
|
# the reported reproduction, cut to the two files that trigger it
|
||||||
|
("tests/test_standalone_setup.py", "tests/test_init.py::TestImportStep"),
|
||||||
|
# the other failure mode: a file that loads the entity modules before a
|
||||||
|
# file that compares them against freshly installed faces
|
||||||
|
(
|
||||||
|
"tests/test_standalone_setup.py",
|
||||||
|
"tests/test_config_flow_protocol.py",
|
||||||
|
"tests/test_init.py",
|
||||||
|
"tests/test_command_routing.py::TestAdbCommandService",
|
||||||
|
"tests/test_command_routing.py::TestFaithfulHaStubs",
|
||||||
|
"tests/test_command_routing.py::TestServiceRegistryKey",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_arbitrary_file_orders_pass(paths):
|
||||||
|
"""Order is the thing under test, so it is executed, not reasoned about."""
|
||||||
|
result = _run(*paths)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stdout[-3000:]
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""tests/test_init.py — the t30 G6 structure test file.
|
||||||
|
|
||||||
|
Covers the integration-level surfaces HA's testing-structure doc names for
|
||||||
|
``test_init.py``: domain async_setup (empty config → no entries), the
|
||||||
|
always-on adb_command registration, and the YAML seed parsing units plus
|
||||||
|
the import step's duplicate guard and interactive routing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
|
||||||
|
# scoped_stubs pattern): the config-flow files install their own stub faces
|
||||||
|
# and pop them per-test; test_init's runs must never leave a hub-stub set
|
||||||
|
# behind that a later file's loader would bind against.
|
||||||
|
_INIT_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.device_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
# the G8 lazy import in async_setup registers this platform module —
|
||||||
|
# without popping it the install() guard would skip the rebuild below
|
||||||
|
"homeassistant.components.media_player",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
"nsh.init",
|
||||||
|
"nsh.media_player",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _integration_module():
|
||||||
|
"""HA stubs + the integration __init__ module under nsh.init."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
if "nsh.init" not in sys.modules:
|
||||||
|
import conftest
|
||||||
|
|
||||||
|
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||||
|
yield sys.modules["nsh.init"]
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
class _SeedEntriesStub:
|
||||||
|
"""config-entries shape for flow seeding tests: entries + flow records."""
|
||||||
|
|
||||||
|
def __init__(self, entries=()):
|
||||||
|
self._entries = list(entries)
|
||||||
|
self.flows = [] # (domain, context, data) records of flow.async_init
|
||||||
|
|
||||||
|
def async_entries(self):
|
||||||
|
return list(self._entries)
|
||||||
|
|
||||||
|
class _Flows:
|
||||||
|
def __init__(self, holder):
|
||||||
|
self._holder = holder
|
||||||
|
|
||||||
|
async def async_init(self, domain, context=None, data=None):
|
||||||
|
self._holder.flows.append((domain, context, data))
|
||||||
|
return {"type": "form", "items": {}}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flow(self):
|
||||||
|
return self._Flows(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _SeedHass(SimpleNamespace):
|
||||||
|
"""Minimal hass for async_setup: config entries, services, tasks."""
|
||||||
|
|
||||||
|
def __init__(self, entries=()):
|
||||||
|
config_entries = _SeedEntriesStub()
|
||||||
|
config_entries.updating_entry_calls = []
|
||||||
|
self._services = {("nvidia_shield", "adb_command"): None}
|
||||||
|
super().__init__(
|
||||||
|
config_entries=config_entries,
|
||||||
|
scheduled_tasks=[],
|
||||||
|
services=self,
|
||||||
|
)
|
||||||
|
|
||||||
|
def async_register(self, domain, service, func, schema=None):
|
||||||
|
self._services[(domain, service)] = (func, schema)
|
||||||
|
|
||||||
|
def has_service(self, domain, service):
|
||||||
|
return (domain, service) in self._services
|
||||||
|
|
||||||
|
def async_create_task(self, coro):
|
||||||
|
task = asyncio.ensure_future(coro)
|
||||||
|
self.scheduled_tasks.append(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainSetup:
|
||||||
|
"""async_setup: empty config, service registration, no entries."""
|
||||||
|
|
||||||
|
async def test_empty_config_creates_nothing(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
result = await _integration_module.async_setup(hass, {})
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert hass.config_entries.flows == [] # nothing seeded
|
||||||
|
assert hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
|
||||||
|
class TestYamlSeedBlocks:
|
||||||
|
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
||||||
|
|
||||||
|
def test_block_normalized(self, _integration_module):
|
||||||
|
from nsh.const import CONF_ADB_PORT, CONF_HOST, DEFAULT_ADB_PORT
|
||||||
|
|
||||||
|
# t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST
|
||||||
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
|
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
seed = blocks[0]
|
||||||
|
assert seed["name"] == "Living"
|
||||||
|
assert seed[CONF_HOST] == "10.0.0.31"
|
||||||
|
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||||
|
assert seed["adb_enabled"] is True
|
||||||
|
assert "unique_id" not in seed # optional key only
|
||||||
|
|
||||||
|
def test_the_current_host_key_seeds_too(self, _integration_module):
|
||||||
|
from nsh.const import CONF_HOST
|
||||||
|
|
||||||
|
seed = _integration_module._yaml_seed_blocks(
|
||||||
|
[{"name": "Desk", CONF_HOST: "shield.local"}]
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
assert seed[CONF_HOST] == "shield.local"
|
||||||
|
assert seed["adb_enabled"] is True # an address names the Shield
|
||||||
|
|
||||||
|
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||||
|
blocks = _integration_module._yaml_seed_blocks(
|
||||||
|
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(blocks) == 1
|
||||||
|
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
|
||||||
|
assert blocks[0]["host"] == "shield.local"
|
||||||
|
assert blocks[0]["adb_port"] == 5000
|
||||||
|
assert blocks[0]["adb_enabled"] is False
|
||||||
|
|
||||||
|
def test_list_and_single_block_shapes(self, _integration_module):
|
||||||
|
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||||
|
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
|
||||||
|
|
||||||
|
assert len(single) == 1 and len(listed) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeeding:
|
||||||
|
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
|
||||||
|
|
||||||
|
async def test_shaped_block_seeds_a_flow(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
await _integration_module.async_setup(
|
||||||
|
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
|
||||||
|
)
|
||||||
|
if hass.scheduled_tasks:
|
||||||
|
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
assert len(hass.config_entries.flows) == 1
|
||||||
|
domain, context, data = hass.config_entries.flows[0]
|
||||||
|
assert domain == "nvidia_shield"
|
||||||
|
assert context["source"] == "import"
|
||||||
|
assert data["host"] == "10.0.0.5" # t41: one address field
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedingCurrentKey:
|
||||||
|
"""The t41 `host` key seeds exactly like the legacy one."""
|
||||||
|
|
||||||
|
async def test_host_key_seeds_a_flow(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
await _integration_module.async_setup(
|
||||||
|
hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]}
|
||||||
|
)
|
||||||
|
if hass.scheduled_tasks:
|
||||||
|
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
assert len(hass.config_entries.flows) == 1
|
||||||
|
assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7"
|
||||||
|
|
||||||
|
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||||
|
hass = _SeedHass(entries=[])
|
||||||
|
|
||||||
|
result = await _integration_module.async_setup(
|
||||||
|
hass, {"nvidia_shield": [{"name": "X"}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert hass.config_entries.flows == [] # hostless → warning only
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportStep:
|
||||||
|
"""async_step_import: duplicate guards + interactive routing."""
|
||||||
|
|
||||||
|
def _flow(self, entries):
|
||||||
|
# the flow module loads under these stubs and the base plumbing is
|
||||||
|
# injected locally — importing test_standalone_setup here would
|
||||||
|
# leak its local stub set into later test files.
|
||||||
|
from nsh.config_flow import NvidiaShieldConfigFlow
|
||||||
|
|
||||||
|
hass = SimpleNamespace(
|
||||||
|
config_entries=_SeedEntriesStub(entries),
|
||||||
|
)
|
||||||
|
hass.config_entries.flows = []
|
||||||
|
flow = NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = hass
|
||||||
|
flow.context = {}
|
||||||
|
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
|
||||||
|
flow.async_set_unique_id = None # set below
|
||||||
|
flow._collected_unique_id = None
|
||||||
|
|
||||||
|
async def set_unique_id(uid):
|
||||||
|
flow._collected_unique_id = uid
|
||||||
|
|
||||||
|
flow.async_set_unique_id = set_unique_id
|
||||||
|
|
||||||
|
def abort_if_unique_id_configured(*args, **kwargs):
|
||||||
|
for entry in hass.config_entries.async_entries():
|
||||||
|
if entry.unique_id == flow._collected_unique_id:
|
||||||
|
from nsh.config_flow import AbortIfConfiguredError
|
||||||
|
|
||||||
|
raise AbortIfConfiguredError(f"{entry.unique_id}")
|
||||||
|
|
||||||
|
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
|
||||||
|
# the base ConfigFlow shows forms through the stub plumbing (matching
|
||||||
|
# how the standalone flow tests install it):
|
||||||
|
flow.async_show_form = lambda **kwargs: {
|
||||||
|
"type": "form",
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {
|
||||||
|
"type": "create_entry",
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
|
||||||
|
flow._async_current_entries = lambda: list(
|
||||||
|
hass.config_entries.async_entries()
|
||||||
|
)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
async def test_ui_configured_host_never_re_seeded(self):
|
||||||
|
from nsh.const import CONF_HOST
|
||||||
|
|
||||||
|
existing = SimpleNamespace(
|
||||||
|
entry_id="ui1",
|
||||||
|
unique_id=None,
|
||||||
|
options={CONF_HOST: "10.0.0.31"},
|
||||||
|
)
|
||||||
|
# options live on the entry (conf zeroconf guard reads them)
|
||||||
|
flow = self._flow([existing])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "A", "adb_host": "10.0.0.31"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
|
||||||
|
async def test_virtual_unique_id_blocks_reseed(self):
|
||||||
|
flow_holder = SimpleNamespace(
|
||||||
|
entry_id="seed1",
|
||||||
|
unique_id="yaml:Living:10.0.0.31",
|
||||||
|
options={},
|
||||||
|
)
|
||||||
|
flow = self._flow([flow_holder])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "Living", "adb_host": "10.0.0.31"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
|
||||||
|
async def test_fresh_block_routes_into_the_user_flow(self):
|
||||||
|
flow = self._flow([])
|
||||||
|
|
||||||
|
result = await flow.async_step_import(
|
||||||
|
{"name": "Fresh", "adb_host": "10.0.0.99"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# interactive pairing — the pre-filled user step, one visit
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
"""t57: installed-app discovery — parsers, the bounded sweep, and the surfaces.
|
||||||
|
|
||||||
|
No real Shield was available, so the command OUTPUT here is the part that is
|
||||||
|
fixture-based, not observed: the shapes are the documented Android ones
|
||||||
|
(``pm list packages`` prints ``package:<id>``; ``resolve-activity --brief``
|
||||||
|
prints the verbose intent then ``package/activity``; ``pm dump`` prints
|
||||||
|
``application-label: '...'``). The parsers are deliberately tolerant because
|
||||||
|
that is the property that matters when a real device's output differs in
|
||||||
|
whitespace or ordering, and every tolerance is pinned below.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import entity_stublib # noqa: E402
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from entity_stublib import load_entity_modules # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_entity_stubs():
|
||||||
|
"""Container for the HA stubs + loaded entity modules (t7 discipline).
|
||||||
|
|
||||||
|
Without the teardown the stubs this file installs leak into whichever test
|
||||||
|
file runs next — tests/test_harness_scoping.py exists because that mistake
|
||||||
|
is invisible until an unrelated file fails.
|
||||||
|
"""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
yield load_entity_modules()
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
from nsh.connection import ShieldConnectionManager # noqa: E402
|
||||||
|
from nsh.const import ( # noqa: E402
|
||||||
|
APP_ICON_FALLBACKS,
|
||||||
|
APP_LIST_TTL,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
|
)
|
||||||
|
from nsh.ha_adb_common import ( # noqa: E402
|
||||||
|
icon_for_package,
|
||||||
|
installed_packages,
|
||||||
|
is_launchable,
|
||||||
|
parse_active_launcher,
|
||||||
|
parse_package_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── realistic device output ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#: `pm list packages -3` on a Shield with a normal app set. Wrapped the way a
|
||||||
|
#: real adb shell answers (a bare list, no header).
|
||||||
|
USER_PACKAGES = (
|
||||||
|
"package:ar.tvplayer.tv\n"
|
||||||
|
"package:com.github.damontecres.wholphin\n"
|
||||||
|
"package:com.netflix.ninja\n"
|
||||||
|
"package:com.google.android.youtube.tv\n"
|
||||||
|
"package:org.xbmc.kodi\n"
|
||||||
|
"package:com.plexapp.android\n"
|
||||||
|
"package:com.spotify.tv.android\n"
|
||||||
|
"package:com.example.handmade\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `cmd package resolve-activity --brief -c android.intent.category.HOME` —
|
||||||
|
#: the verbose header then the component, exactly as Android prints it.
|
||||||
|
LAUNCHER_RESOLVED = (
|
||||||
|
"priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false\n"
|
||||||
|
"com.google.android.tvlauncher/com.google.android.apps.tv.launcherx.MainActivity\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `pm dump` for an app whose manifest and locale variants both exist. The
|
||||||
|
#: parser takes the first default-locale label and ignores the localized ones.
|
||||||
|
DUMP_TIVIMATE = (
|
||||||
|
"Package [ar.tvplayer.tv] (7f1a2b3c):\n"
|
||||||
|
" application-label:'TiviMate'\n"
|
||||||
|
" application-label-de:'TiviMate DE'\n"
|
||||||
|
" application-label-fr:'TiviMate FR'\n"
|
||||||
|
" versionName=4.7.0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: A dump that RAN and carries no label at all — several system/OEM packages
|
||||||
|
#: look like this. It is a definitive negative, not a failed fetch.
|
||||||
|
DUMP_LABEL_LESS = (
|
||||||
|
"Package [com.example.handmade] (aa11bb22):\n"
|
||||||
|
" versionName=1.0\n"
|
||||||
|
" codePath=/data/app/com.example.handmade\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DiscoveryPoller:
|
||||||
|
"""A poller whose shell answers the discovery commands from fixtures."""
|
||||||
|
|
||||||
|
def __init__(self, *, packages=USER_PACKAGES, launcher=LAUNCHER_RESOLVED,
|
||||||
|
dumps=None, failure=False):
|
||||||
|
self.connected = True
|
||||||
|
self.connected_override = True
|
||||||
|
self.packages = packages
|
||||||
|
self.launcher = launcher
|
||||||
|
self.dumps = dict(dumps or {})
|
||||||
|
self.failure = failure
|
||||||
|
self.shell_calls: list[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self):
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def shell(self, cmd: str):
|
||||||
|
self.shell_calls.append(cmd)
|
||||||
|
if self.failure:
|
||||||
|
return None
|
||||||
|
if "pm list packages" in cmd:
|
||||||
|
return self.packages
|
||||||
|
if "resolve-activity" in cmd:
|
||||||
|
return self.launcher
|
||||||
|
if "pm dump" in cmd:
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
for package, dump in self.dumps.items():
|
||||||
|
if shlex.quote(package) in cmd or package in cmd:
|
||||||
|
return dump
|
||||||
|
return "__NSH_EXIT:0\n" # ran, no label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(poller):
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackageListParsing:
|
||||||
|
"""`pm list packages -3`."""
|
||||||
|
|
||||||
|
def test_real_output_parses_one_package_per_line(self):
|
||||||
|
assert parse_package_list(USER_PACKAGES) == [
|
||||||
|
"ar.tvplayer.tv",
|
||||||
|
"com.github.damontecres.wholphin",
|
||||||
|
"com.netflix.ninja",
|
||||||
|
"com.google.android.youtube.tv",
|
||||||
|
"org.xbmc.kodi",
|
||||||
|
"com.plexapp.android",
|
||||||
|
"com.spotify.tv.android",
|
||||||
|
"com.example.handmade",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_noise_and_duplicates_are_tolerated(self):
|
||||||
|
noisy = (
|
||||||
|
"adb: connecting to 10.0.0.9:5555\n"
|
||||||
|
"package:com.a\n"
|
||||||
|
"\n"
|
||||||
|
"package:com.a\n"
|
||||||
|
"package:com.b \n"
|
||||||
|
"some warning line\n"
|
||||||
|
)
|
||||||
|
assert parse_package_list(noisy) == ["com.a", "com.b"]
|
||||||
|
|
||||||
|
def test_empty_and_none_are_an_empty_list(self):
|
||||||
|
assert parse_package_list(None) == []
|
||||||
|
assert parse_package_list("") == []
|
||||||
|
|
||||||
|
def test_a_package_id_is_sanity_checked(self):
|
||||||
|
assert is_launchable("com.example.app")
|
||||||
|
assert is_launchable("ar.tvplayer.tv")
|
||||||
|
assert not is_launchable("")
|
||||||
|
assert not is_launchable("notapackage")
|
||||||
|
assert not is_launchable("com..b")
|
||||||
|
|
||||||
|
|
||||||
|
class TestLauncherParsing:
|
||||||
|
"""`cmd package resolve-activity --brief -c ...HOME`."""
|
||||||
|
|
||||||
|
def test_the_component_after_the_verbose_header_is_the_launcher(self):
|
||||||
|
assert parse_active_launcher(LAUNCHER_RESOLVED) == "com.google.android.tvlauncher"
|
||||||
|
|
||||||
|
def test_activity_only_output_still_parses(self):
|
||||||
|
assert parse_active_launcher("com.google.android.tvlauncher/.MainActivity") == (
|
||||||
|
"com.google.android.tvlauncher"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nothing_resolved_is_none(self):
|
||||||
|
assert parse_active_launcher("No activity found") is None
|
||||||
|
assert parse_active_launcher(None) is None
|
||||||
|
|
||||||
|
def test_the_tv_settings_entry_point_is_not_a_launcher(self):
|
||||||
|
# a HOME resolution can land on the settings app's entry point; that is
|
||||||
|
# not the app a user opens
|
||||||
|
output = "com.android.tv.settings/.MainActivity"
|
||||||
|
assert parse_active_launcher(output) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledPackagesMerge:
|
||||||
|
"""User apps plus the active launcher, de-duplicated."""
|
||||||
|
|
||||||
|
def test_launcher_is_added_last(self):
|
||||||
|
packages = installed_packages(USER_PACKAGES, LAUNCHER_RESOLVED)
|
||||||
|
assert packages[-1] == "com.google.android.tvlauncher"
|
||||||
|
assert packages[0] == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
def test_a_third_party_launcher_is_not_duplicated(self):
|
||||||
|
user = "package:com.example.launcher\npackage:com.a\n"
|
||||||
|
launcher = "com.example.launcher/.MainActivity"
|
||||||
|
assert installed_packages(user, launcher) == ["com.example.launcher", "com.a"]
|
||||||
|
|
||||||
|
def test_no_launcher_still_yields_the_user_apps(self):
|
||||||
|
assert installed_packages(USER_PACKAGES, None) == parse_package_list(USER_PACKAGES)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIconHeuristic:
|
||||||
|
"""Icons are a curated map plus a generic fallback — and always a string."""
|
||||||
|
|
||||||
|
def test_a_known_package_gets_its_curated_icon(self):
|
||||||
|
assert icon_for_package("com.netflix.ninja", APP_ICON_FALLBACKS) == "mdi:netflix"
|
||||||
|
|
||||||
|
def test_an_unknown_package_gets_the_generic_icon(self):
|
||||||
|
assert icon_for_package("com.example.handmade", APP_ICON_FALLBACKS) == "mdi:television"
|
||||||
|
|
||||||
|
def test_the_result_is_always_a_string(self):
|
||||||
|
for package in ("", None, "com.example.handmade", "ar.tvplayer.tv"):
|
||||||
|
icon = icon_for_package(package, APP_ICON_FALLBACKS)
|
||||||
|
assert isinstance(icon, str) and icon
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverySweep:
|
||||||
|
"""The bounded sweep on the connection manager."""
|
||||||
|
|
||||||
|
async def test_a_sweep_lists_labels_and_icons_every_app(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={
|
||||||
|
"ar.tvplayer.tv": DUMP_TIVIMATE,
|
||||||
|
"com.example.handmade": DUMP_LABEL_LESS,
|
||||||
|
})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
packages = [row["package"] for row in rows]
|
||||||
|
assert "ar.tvplayer.tv" in packages
|
||||||
|
assert "com.netflix.ninja" in packages
|
||||||
|
assert "com.google.android.tvlauncher" in packages # the launcher is there
|
||||||
|
by_pkg = {row["package"]: row for row in rows}
|
||||||
|
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate" # from the dump
|
||||||
|
assert by_pkg["ar.tvplayer.tv"]["icon"] == "mdi:television-classic"
|
||||||
|
# a label-less app keeps its discovered name (the package id) and is
|
||||||
|
# still a row — it must not vanish from the table
|
||||||
|
assert by_pkg["com.example.handmade"]["name"] == "com.example.handmade"
|
||||||
|
# and every row's name/icon is a string
|
||||||
|
for row in rows:
|
||||||
|
assert isinstance(row["name"], str) and row["name"]
|
||||||
|
assert isinstance(row["icon"], str) and row["icon"]
|
||||||
|
|
||||||
|
async def test_the_live_bug_the_discovered_list_is_a_superset(self):
|
||||||
|
"""The complaint in one assertion: every app the user has, plus the
|
||||||
|
curated four, is offered — not just the curated four."""
|
||||||
|
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||||
|
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
packages = {row["package"] for row in rows}
|
||||||
|
|
||||||
|
# the curated list the integration used to fall back to
|
||||||
|
assert set(DEFAULT_APP_DIRECTORY) <= packages
|
||||||
|
# ...and the apps it missed, which is what the user reported
|
||||||
|
for package in (
|
||||||
|
"ar.tvplayer.tv", # TiviMate
|
||||||
|
"com.github.damontecres.wholphin", # Wholphin
|
||||||
|
"com.netflix.ninja", # Netflix
|
||||||
|
"com.google.android.youtube.tv", # YouTube
|
||||||
|
"org.xbmc.kodi",
|
||||||
|
"com.plexapp.android",
|
||||||
|
"com.spotify.tv.android",
|
||||||
|
"com.example.handmade",
|
||||||
|
):
|
||||||
|
assert package in packages, package
|
||||||
|
assert "com.google.android.tvlauncher" in packages # + the launcher
|
||||||
|
# a system app the device never lists as -3 is NOT invented here
|
||||||
|
assert "com.android.systemui" not in packages
|
||||||
|
|
||||||
|
async def test_the_sweep_issues_the_documented_commands(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert "pm list packages -3" in poller.shell_calls
|
||||||
|
assert any("resolve-activity" in cmd for cmd in poller.shell_calls)
|
||||||
|
assert "android.intent.category.HOME" in " ".join(poller.shell_calls)
|
||||||
|
|
||||||
|
async def test_labels_are_fetched_in_a_bounded_burst(self):
|
||||||
|
"""A full sweep must not serialise one shell at a time, and must not flood."""
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
concurrent = {"now": 0, "peak": 0, "total": 0}
|
||||||
|
|
||||||
|
real_shell = poller.shell
|
||||||
|
|
||||||
|
async def _counted(cmd):
|
||||||
|
if "pm dump" in cmd:
|
||||||
|
concurrent["now"] += 1
|
||||||
|
concurrent["total"] += 1
|
||||||
|
concurrent["peak"] = max(concurrent["peak"], concurrent["now"])
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0) # yield so overlap is observable
|
||||||
|
return await real_shell(cmd)
|
||||||
|
finally:
|
||||||
|
concurrent["now"] -= 1
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
poller.shell = _counted
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert len(rows) >= 5
|
||||||
|
assert concurrent["total"] >= 5
|
||||||
|
# bounded: never more than the configured workers in flight at once
|
||||||
|
from nsh.const import APP_SWEEP_CONCURRENCY
|
||||||
|
|
||||||
|
assert concurrent["peak"] <= APP_SWEEP_CONCURRENCY
|
||||||
|
assert concurrent["peak"] > 1 # ...and genuinely concurrent, not serial
|
||||||
|
|
||||||
|
async def test_a_cached_table_is_reused_and_costs_no_shell(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
first = await manager.async_refresh_installed_apps()
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
second = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert second == first
|
||||||
|
assert len(poller.shell_calls) == calls # cached: zero new shells
|
||||||
|
assert manager.installed_apps_stale is False
|
||||||
|
|
||||||
|
async def test_force_refreshes_even_a_fresh_table(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
await manager.async_refresh_installed_apps(force=True)
|
||||||
|
|
||||||
|
assert len(poller.shell_calls) > calls
|
||||||
|
|
||||||
|
async def test_a_stale_table_is_not_served(self):
|
||||||
|
now = [1000.0]
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||||
|
)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
assert manager.installed_apps()
|
||||||
|
|
||||||
|
now[0] += APP_LIST_TTL + 1
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
assert manager.installed_apps_stale is True
|
||||||
|
|
||||||
|
async def test_the_watchdog_never_sweeps(self):
|
||||||
|
"""Acceptance: nothing discovery-related runs on a state poll.
|
||||||
|
|
||||||
|
The watchdog cycle is what a poll drives. It may drain ONE label fetch
|
||||||
|
(the t24/t55 rule for the live entity path), but it must never list
|
||||||
|
packages or fan out into a discovery burst.
|
||||||
|
"""
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert not [c for c in poller.shell_calls if "pm list packages" in c]
|
||||||
|
assert not [c for c in poller.shell_calls if "resolve-activity" in c]
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
|
||||||
|
async def test_concurrent_callers_share_one_sweep(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
list_calls = {"n": 0}
|
||||||
|
real_shell = poller.shell
|
||||||
|
|
||||||
|
async def _counted(cmd):
|
||||||
|
if "pm list packages" in cmd:
|
||||||
|
list_calls["n"] += 1
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
poller.shell = _counted
|
||||||
|
results = await asyncio.gather(
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
manager.async_refresh_installed_apps(force=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_calls["n"] == 1 # one sweep, shared
|
||||||
|
assert results[0] == results[1] == results[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveryDegradesGracefully:
|
||||||
|
"""ADB off, down or mute: an empty list and a note, never a crash or a loop.
|
||||||
|
|
||||||
|
t62 F1: "could not ask" and "asked, nothing there" are different answers.
|
||||||
|
Only the second one is cached; the first is retried.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_no_poller_is_an_empty_list(self):
|
||||||
|
manager = ShieldConnectionManager(hass=None, entry=None, poller=None)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
|
||||||
|
async def test_a_disconnected_poller_is_an_empty_list(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager._poller = poller
|
||||||
|
poller.connected = False
|
||||||
|
# the manager's own connected flag is what the sweep reads
|
||||||
|
assert manager.connected is False
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert poller.shell_calls == [] # nothing was even attempted
|
||||||
|
|
||||||
|
async def test_a_mute_transport_is_spaced_but_not_stamped(self):
|
||||||
|
"""t62 F1: a failed sweep is retried — but not once per state poll."""
|
||||||
|
now = [1000.0]
|
||||||
|
poller = _DiscoveryPoller(failure=True)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=lambda: now[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows == []
|
||||||
|
assert poller.shell_calls # it tried
|
||||||
|
assert manager.installed_apps_status == "unknown" # NOT an empty answer
|
||||||
|
# the next poll inside the window does NOT re-shell (no hammering)..
|
||||||
|
calls = len(poller.shell_calls)
|
||||||
|
assert await manager.async_refresh_installed_apps() == []
|
||||||
|
assert len(poller.shell_calls) == calls
|
||||||
|
# ..but nothing was stamped as an answer, so once the window passes the
|
||||||
|
# sweep runs again. Pre-fix it waited out APP_LIST_TTL (6 h) instead.
|
||||||
|
now[0] += manager.APP_SWEEP_RETRY_SECONDS + 1
|
||||||
|
assert await manager.async_refresh_installed_apps() == []
|
||||||
|
assert len(poller.shell_calls) > calls
|
||||||
|
|
||||||
|
async def test_a_sweep_that_could_not_run_is_not_a_fresh_answer(self):
|
||||||
|
"""t62 F1 (red proof): startup with ADB down must not BE the answer.
|
||||||
|
|
||||||
|
The entity's one startup sweep runs with the poller down. Once ADB comes
|
||||||
|
back, ``homeassistant.update_entity`` (``sensor.async_update``) must
|
||||||
|
recover the table. Pre-fix the startup sweep stamped an empty answer, so
|
||||||
|
this call issued ZERO shells and kept reporting 0/"pending" for
|
||||||
|
APP_LIST_TTL = 6 h.
|
||||||
|
"""
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
poller.connected = False
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
assert await manager.async_refresh_installed_apps() == [] # the startup
|
||||||
|
assert poller.shell_calls == [] # it was not even attempted
|
||||||
|
assert manager.installed_apps_stale is True # ...so it is not an answer
|
||||||
|
assert manager.installed_apps_status == "unknown"
|
||||||
|
|
||||||
|
poller.connected = True # ADB is back
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
assert rows, "update_entity must recover the table once ADB returns"
|
||||||
|
assert "pm list packages -3" in poller.shell_calls
|
||||||
|
assert manager.installed_apps_status == "ok"
|
||||||
|
|
||||||
|
async def test_a_genuinely_empty_answer_is_ok(self):
|
||||||
|
"""t62 F1: 'ok' must be reachable for a really empty device."""
|
||||||
|
poller = _DiscoveryPoller(packages="", launcher="")
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
assert await manager.async_refresh_installed_apps() == []
|
||||||
|
|
||||||
|
assert poller.shell_calls # the commands RAN and answered
|
||||||
|
assert manager.installed_apps_stale is False # stamped: a real answer
|
||||||
|
assert manager.installed_apps_status == "ok"
|
||||||
|
|
||||||
|
async def test_the_accessor_never_shells(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
assert manager.installed_apps() == []
|
||||||
|
assert poller.shell_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveryUsesTheCachedLabelPath:
|
||||||
|
"""Acceptance: labels come from the pm-dump path AND its 24 h cache."""
|
||||||
|
|
||||||
|
async def test_a_second_sweep_reuses_the_cached_labels(self):
|
||||||
|
"""The 24 h cache is what makes a re-sweep cheap — negatives included.
|
||||||
|
|
||||||
|
A label-less package is a DEFINITIVE answer, so the second sweep must
|
||||||
|
not re-dump it. The check goes through the cache directly, because the
|
||||||
|
display accessor (`app_label`) cannot tell "no label" from "not asked".
|
||||||
|
"""
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
dumps = [c for c in poller.shell_calls if "pm dump" in c]
|
||||||
|
assert dumps # the first sweep labelled everything it could
|
||||||
|
assert len(dumps) >= 5 # ...and it did dump for the unlabelled ones
|
||||||
|
|
||||||
|
# the cache now answers for EVERY package, so a forced re-sweep lists
|
||||||
|
# again but dumps nothing at all (the 24 h label cache, not the table TTL)
|
||||||
|
manager._installed_apps = None # forget the table, keep the labels
|
||||||
|
rows = await manager.async_refresh_installed_apps(force=True)
|
||||||
|
|
||||||
|
assert [c for c in poller.shell_calls if "pm dump" in c] == dumps
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||||
|
by_pkg = {row["package"]: row for row in rows}
|
||||||
|
assert by_pkg["ar.tvplayer.tv"]["name"] == "TiviMate"
|
||||||
|
assert by_pkg["org.xbmc.kodi"]["name"] == "org.xbmc.kodi"
|
||||||
|
|
||||||
|
async def test_a_failed_label_is_retried_by_the_next_sweep(self):
|
||||||
|
"""UNIT 0 semantics at scale: a transient failure is not permanent."""
|
||||||
|
poller = _DiscoveryPoller(dumps={"org.xbmc.kodi": "application-label: 'Kodi'"})
|
||||||
|
manager = _manager(poller)
|
||||||
|
real_shell = poller.shell
|
||||||
|
state = {"fail_kodi": True}
|
||||||
|
|
||||||
|
async def _flaky(cmd):
|
||||||
|
if state["fail_kodi"] and "pm dump" in cmd and "kodi" in cmd:
|
||||||
|
poller.shell_calls.append(cmd)
|
||||||
|
return None # the transport delivered nothing
|
||||||
|
return await real_shell(cmd)
|
||||||
|
|
||||||
|
poller.shell = _flaky
|
||||||
|
rows = await manager.async_refresh_installed_apps()
|
||||||
|
by_pkg = {row["package"]: row for row in rows}
|
||||||
|
assert by_pkg["org.xbmc.kodi"]["name"] == "org.xbmc.kodi" # fell back
|
||||||
|
assert manager.app_label("org.xbmc.kodi") is None # NOT cached
|
||||||
|
|
||||||
|
state["fail_kodi"] = False
|
||||||
|
manager._installed_apps = None
|
||||||
|
rows2 = await manager.async_refresh_installed_apps()
|
||||||
|
by_pkg2 = {row["package"]: row for row in rows2}
|
||||||
|
assert by_pkg2["org.xbmc.kodi"]["name"] == "Kodi" # recovered
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstalledAppsSensor:
|
||||||
|
"""The dashboard surface: state = count, attribute = the table."""
|
||||||
|
|
||||||
|
def _sensor(self, manager, options=None):
|
||||||
|
sensor_module = load_entity_modules()["sensor"]
|
||||||
|
entry = type("E", (), {})()
|
||||||
|
entry.entry_id = "e1"
|
||||||
|
entry.options = dict(options or {})
|
||||||
|
entry.runtime_data = manager
|
||||||
|
entry.title = "mdarcy"
|
||||||
|
sensor = sensor_module.NvidiaShieldAppsSensor.__new__(
|
||||||
|
sensor_module.NvidiaShieldAppsSensor
|
||||||
|
)
|
||||||
|
sensor.hass = entity_stublib.FakeHass()
|
||||||
|
sensor._entry = entry
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
async def test_state_is_the_count_and_the_attribute_is_the_table(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
sensor = self._sensor(manager)
|
||||||
|
|
||||||
|
assert sensor.native_value == len(manager.installed_apps())
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
assert attrs["discovery"] == "ok"
|
||||||
|
assert any(
|
||||||
|
app["package"] == "ar.tvplayer.tv" and app["name"] == "TiviMate"
|
||||||
|
for app in attrs["installed_apps"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_discovery_yet_is_an_empty_table_with_a_reason(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
sensor = self._sensor(manager)
|
||||||
|
|
||||||
|
assert sensor.native_value == 0
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
assert attrs["installed_apps"] == []
|
||||||
|
assert attrs["discovery"] in ("pending", "not_connected", "adb_disabled")
|
||||||
|
|
||||||
|
async def test_an_empty_discovery_is_ok_not_pending(self):
|
||||||
|
"""t62 F1: a sweep that RAN and found nothing is the answer 'ok'."""
|
||||||
|
poller = _DiscoveryPoller(packages="", launcher="")
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
sensor = self._sensor(manager, {"adb_enabled": True})
|
||||||
|
|
||||||
|
assert sensor.native_value == 0
|
||||||
|
assert sensor.extra_state_attributes == {
|
||||||
|
"installed_apps": [],
|
||||||
|
"discovery": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_a_sweep_that_could_not_run_is_unknown_not_pending(self):
|
||||||
|
"""t62 F1: 'pending' used to cover both "running" and "could not run"."""
|
||||||
|
poller = _DiscoveryPoller(failure=True)
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
sensor = self._sensor(manager, {"adb_enabled": True})
|
||||||
|
|
||||||
|
assert sensor.extra_state_attributes == {
|
||||||
|
"installed_apps": [],
|
||||||
|
"discovery": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_adb_disabled_is_reported_as_such(self):
|
||||||
|
poller = _DiscoveryPoller()
|
||||||
|
manager = _manager(poller)
|
||||||
|
poller.connected = False
|
||||||
|
sensor = self._sensor(manager, {"adb_enabled": False})
|
||||||
|
|
||||||
|
attrs = sensor.extra_state_attributes
|
||||||
|
|
||||||
|
assert attrs == {"installed_apps": [], "discovery": "adb_disabled"}
|
||||||
|
|
||||||
|
async def test_the_sensor_refresh_is_a_no_op_without_a_manager(self):
|
||||||
|
sensor = self._sensor(None)
|
||||||
|
sensor._entry.runtime_data = None
|
||||||
|
|
||||||
|
assert sensor.native_value == 0
|
||||||
|
assert sensor.extra_state_attributes["installed_apps"] == []
|
||||||
|
await sensor.async_update() # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoveredAppsStillRespectTheUsersMap:
|
||||||
|
"""The user's own map is an OVERRIDE, and discovery fills the rest (t57)."""
|
||||||
|
|
||||||
|
async def test_a_mapped_package_keeps_the_users_name_not_the_device_label(self):
|
||||||
|
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_refresh_installed_apps()
|
||||||
|
|
||||||
|
# the shared resolver is what the sensor and the player use; discovery
|
||||||
|
# only supplies the device label underneath the user's map
|
||||||
|
from nsh.media_player import resolve_app_name
|
||||||
|
|
||||||
|
options = {CONF_APP_NAME_MAP: {"ar.tvplayer.tv": "Telly"}}
|
||||||
|
assert (
|
||||||
|
resolve_app_name(
|
||||||
|
"ar.tvplayer.tv", options[CONF_APP_NAME_MAP], manager.app_label
|
||||||
|
)
|
||||||
|
== "Telly"
|
||||||
|
)
|
||||||
|
# ...and the device label is what discovery put UNDER it, so dropping
|
||||||
|
# the map entry still resolves to a real name rather than a package id
|
||||||
|
assert (
|
||||||
|
resolve_app_name("ar.tvplayer.tv", {}, manager.app_label) == "TiviMate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_label_exit_marker_never_becomes_a_name():
|
||||||
|
"""The marker is a transport detail; it must not leak into a label."""
|
||||||
|
from nsh.ha_adb_common import parse_application_label
|
||||||
|
|
||||||
|
assert parse_application_label("__NSH_EXIT:0\n") is None
|
||||||
|
assert parse_application_label(DUMP_TIVIMATE + "__NSH_EXIT:0\n") == "TiviMate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_importing_the_discovery_module_needs_no_home_assistant():
|
||||||
|
"""The parsers live in ha_adb_common and stay importable without HA."""
|
||||||
|
import nsh.ha_adb_common.installed_apps as module
|
||||||
|
|
||||||
|
assert module.__doc__ and "pm list packages -3" in module.__doc__
|
||||||
|
assert not [n for n in dir(module) if n.startswith("hass")]
|
||||||
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
|
|||||||
assert sessions[0].position is None
|
assert sessions[0].position is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaybackStateBufferingT55:
|
||||||
|
"""t55: Android PlaybackState 6 is BUFFERING, mapped in the parser.
|
||||||
|
|
||||||
|
The Shield's own sessions report the same integer stock Android does (6),
|
||||||
|
so no vendor-specific variant is treated as buffering — a device variant
|
||||||
|
would be a template field on the same PlaybackState line, never a
|
||||||
|
different integer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _buffering_dump(self, state=6, speed="0.0"):
|
||||||
|
return (
|
||||||
|
"Sessions Stack - have 1 sessions:\n"
|
||||||
|
" Some app session com.netflix.ninja/Session (userId=0)\n"
|
||||||
|
" package=com.netflix.ninja\n"
|
||||||
|
" active=true\n"
|
||||||
|
f" state=PlaybackState {{state={state}, position=0, "
|
||||||
|
f"buffered position=2000, speed={speed}, updated=900, actions=1, "
|
||||||
|
"custom actions=[], active item id=0, error=null}\n"
|
||||||
|
" metadata: size=2, description=Movie Title, Some Artist\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_state_6_is_buffering(self):
|
||||||
|
sessions = parse_all_media_sessions(self._buffering_dump())
|
||||||
|
assert len(sessions) == 1
|
||||||
|
session = sessions[0]
|
||||||
|
assert session.buffering is True
|
||||||
|
assert session.playing is False
|
||||||
|
assert session.paused is False
|
||||||
|
assert session.title == "Movie Title"
|
||||||
|
|
||||||
|
def test_state_3_and_2_stay_playing_and_paused(self):
|
||||||
|
for state, attr in ((3, "playing"), (2, "paused")):
|
||||||
|
sessions = parse_all_media_sessions(self._buffering_dump(state=state))
|
||||||
|
session = sessions[0]
|
||||||
|
assert session.buffering is False
|
||||||
|
assert getattr(session, attr) is True
|
||||||
|
|
||||||
|
def test_state_6_wins_when_the_dump_says_speed_zero_too(self):
|
||||||
|
"""The buffering signal is the integer, not the speed field."""
|
||||||
|
sessions = parse_all_media_sessions(self._buffering_dump(state=6))
|
||||||
|
assert sessions[0].buffering is True
|
||||||
|
|
||||||
|
def test_buffering_outranks_a_paused_session_in_the_pick_order(self):
|
||||||
|
sessions = parse_all_media_sessions(
|
||||||
|
self._buffering_dump()
|
||||||
|
+ " Other app session com.example.player/Session (userId=0)\n"
|
||||||
|
" package=com.example.player\n"
|
||||||
|
" active=true\n"
|
||||||
|
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||||
|
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||||
|
"error=null}\n"
|
||||||
|
" metadata: size=1, description=Paused Tune\n"
|
||||||
|
)
|
||||||
|
assert [s.package for s in sessions] == [
|
||||||
|
"com.netflix.ninja",
|
||||||
|
"com.example.player",
|
||||||
|
]
|
||||||
|
# and parse_media_session (no foreground preference) picks buffering
|
||||||
|
dump = self._buffering_dump() + (
|
||||||
|
" Other app session com.example.player/Session (userId=0)\n"
|
||||||
|
" package=com.example.player\n"
|
||||||
|
" active=true\n"
|
||||||
|
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||||
|
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||||
|
"error=null}\n"
|
||||||
|
" metadata: size=1, description=Paused Tune\n"
|
||||||
|
)
|
||||||
|
assert parse_media_session(dump).package == "com.netflix.ninja"
|
||||||
|
|
||||||
|
|
||||||
class TestAudioPlaybackSection:
|
class TestAudioPlaybackSection:
|
||||||
"""The "Audio playback (lastly played comes first)" section lists apps
|
"""The "Audio playback (lastly played comes first)" section lists apps
|
||||||
that recently played audio even with no MediaSession (R2)."""
|
that recently played audio even with no MediaSession (R2)."""
|
||||||
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
|
|||||||
assert parse_audio_playback("") == []
|
assert parse_audio_playback("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_apps_buffering_state_t55():
|
||||||
|
"""t55: a buffering session (and a buffering last-known) is named buffering."""
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
{"package": "com.netflix.ninja"},
|
||||||
|
{"package": "ar.tvplayer.tv"},
|
||||||
|
]
|
||||||
|
sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)]
|
||||||
|
last_known = {
|
||||||
|
"ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True)
|
||||||
|
}
|
||||||
|
|
||||||
|
apps = running_apps_from_tasks(
|
||||||
|
tasks, sessions, foreground_package=None, last_known=last_known
|
||||||
|
)
|
||||||
|
|
||||||
|
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
|
||||||
|
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
|
||||||
|
assert netflix["state"] == "buffering"
|
||||||
|
assert tivi["last_known_state"] == "buffering"
|
||||||
|
|
||||||
|
|
||||||
def test_running_apps_last_known_enrichment():
|
def test_running_apps_last_known_enrichment():
|
||||||
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
|
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
|
||||||
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
"""tests/test_media_sources.py — source_list, source and native select_source (t47).
|
||||||
|
|
||||||
|
The last piece a universal-media-player replacement needs from this integration:
|
||||||
|
a dropdown of the apps (``source_list``), an answer to "what is on screen"
|
||||||
|
(``source``), and a way to pick one (``media_player.select_source``). All three read
|
||||||
|
the SAME app directory — the user's Apps map, else the curated default — so the
|
||||||
|
dropdown cannot offer a name the launch path cannot resolve.
|
||||||
|
|
||||||
|
The t44 rule still holds: a configured ``select_source`` script takes precedence,
|
||||||
|
because selecting a source can be more than launching an app. Without one, the name
|
||||||
|
resolves back to its package and goes out over the shared ``play_media("app", …)``
|
||||||
|
path; an unknown name is a translated error, never a silent no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
from nsh.const import CONF_APP_NAME_MAP, CONF_COMMAND_SCRIPTS, SCRIPT_VAR_APP_NAME
|
||||||
|
from test_command_routing import make_player
|
||||||
|
|
||||||
|
TIVIMATE = "ar.tvplayer.tv"
|
||||||
|
SCRIPT = "script.shield_source"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="modules", autouse=True)
|
||||||
|
def scoped_modules():
|
||||||
|
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
try:
|
||||||
|
yield entity_stublib.load_entity_modules()
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def _player(modules, *, app_map=None, scripts=None, foreground=None):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
if app_map is not None:
|
||||||
|
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||||
|
if scripts is not None:
|
||||||
|
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||||
|
if foreground is not None:
|
||||||
|
player._manager._on_current_app(foreground)
|
||||||
|
return player, served
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceList:
|
||||||
|
"""The dropdown: friendly names, in a stable order, from the one directory."""
|
||||||
|
|
||||||
|
def test_it_comes_from_the_apps_map(self, modules):
|
||||||
|
player, _served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={"com.custom.one": "One", "com.custom.two": "Two"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.source_list == ["One", "Two"] # map order, not shuffled
|
||||||
|
|
||||||
|
def test_it_falls_back_to_the_curated_directory(self, modules):
|
||||||
|
player, _served = _player(modules) # no map configured
|
||||||
|
|
||||||
|
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
|
||||||
|
|
||||||
|
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
|
||||||
|
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
|
||||||
|
|
||||||
|
An empty entry is worst than useless: it is a dropdown item that
|
||||||
|
select_source has to reject as an unknown source.
|
||||||
|
"""
|
||||||
|
player, _served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={
|
||||||
|
"com.blank": "",
|
||||||
|
TIVIMATE: "TiviMate",
|
||||||
|
"com.spaces": " ",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.source_list == ["TiviMate", "Netflix"]
|
||||||
|
assert "" not in player.source_list
|
||||||
|
|
||||||
|
def test_the_list_is_the_normalized_directory_exactly(self, modules):
|
||||||
|
"""Same accessor, same order, same bytes — a blank entry excepted (t49).
|
||||||
|
|
||||||
|
t61 removed the browse tree that used to be the second consumer of this
|
||||||
|
one accessor; the dropdown and the reverse lookup are still fed from it,
|
||||||
|
so a row can only launch the app it shows.
|
||||||
|
"""
|
||||||
|
for app_map in (
|
||||||
|
None, # the curated default
|
||||||
|
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
|
||||||
|
):
|
||||||
|
player, _served = _player(modules, app_map=app_map)
|
||||||
|
|
||||||
|
assert player.source_list == list(player._app_directory.values())
|
||||||
|
|
||||||
|
async def test_the_survivors_still_resolve_and_launch(self, modules):
|
||||||
|
"""The filter must not cost a resolvable name (t49)."""
|
||||||
|
player, _served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={"com.blank": "", TIVIMATE: "TiviMate", "com.blank2": " "},
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in player.source_list:
|
||||||
|
assert player._source_package(name) is not None
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
# the blank entries are not selectable, and never launch
|
||||||
|
assert player._source_package("") is None
|
||||||
|
assert player._source_package(" ") is None
|
||||||
|
|
||||||
|
def test_every_offered_name_resolves_to_a_package(self, modules):
|
||||||
|
"""The invariant the shared directory buys: no name without a destination.
|
||||||
|
|
||||||
|
t51 extends it to the hand-edited shapes: blanks, padded names and two
|
||||||
|
entries normalizing to one name all still leave only resolvable offers.
|
||||||
|
"""
|
||||||
|
for app_map in (
|
||||||
|
None,
|
||||||
|
{"com.custom.one": "One"},
|
||||||
|
{
|
||||||
|
"com.blank": "",
|
||||||
|
"com.blank.package": " ",
|
||||||
|
TIVIMATE: " TiviMate ",
|
||||||
|
"com.a": "Netflix",
|
||||||
|
"com.b": " Netflix ",
|
||||||
|
"": "Ghost",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
player, _served = _player(modules, app_map=app_map)
|
||||||
|
|
||||||
|
for name in player.source_list:
|
||||||
|
assert player._source_package(name) is not None, name
|
||||||
|
|
||||||
|
|
||||||
|
class TestSource:
|
||||||
|
"""`source` is the foreground app's resolved name — the app_name chain."""
|
||||||
|
|
||||||
|
def test_it_resolves_through_the_apps_map(self, modules):
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={TIVIMATE: "TiviMate"}, foreground=TIVIMATE
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.source == "TiviMate"
|
||||||
|
assert player.source == player.app_name # one truth, not two
|
||||||
|
|
||||||
|
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
|
||||||
|
player, _served = _player(modules, foreground="com.netflix.ninja")
|
||||||
|
player._manager.cache_app_label("com.netflix.ninja", "Netflix")
|
||||||
|
|
||||||
|
assert player.source == "Netflix"
|
||||||
|
|
||||||
|
def test_it_falls_back_to_the_raw_package(self, modules):
|
||||||
|
player, _served = _player(modules, foreground="com.unknown.app")
|
||||||
|
|
||||||
|
assert player.source == "com.unknown.app"
|
||||||
|
|
||||||
|
def test_it_is_unknown_when_nothing_has_reported(self, modules):
|
||||||
|
player, _served = _player(modules)
|
||||||
|
|
||||||
|
assert player.source is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectSource:
|
||||||
|
"""Picking a source: script first, else the native launch, else an error."""
|
||||||
|
|
||||||
|
async def test_a_name_launches_its_package(self, modules):
|
||||||
|
player, served = _player(
|
||||||
|
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
assert served == [] # no script was configured, and nothing else left
|
||||||
|
|
||||||
|
async def test_the_curated_names_launch_too(self, modules):
|
||||||
|
player, _served = _player(modules)
|
||||||
|
|
||||||
|
await player.async_select_source("Netflix")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == ["com.netflix.ninja"]
|
||||||
|
|
||||||
|
async def test_a_raw_package_and_any_case_are_accepted(self, modules):
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.async_select_source(TIVIMATE)
|
||||||
|
await player.async_select_source("tivimate")
|
||||||
|
await player.async_select_source(" NETFLIX ")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == [
|
||||||
|
TIVIMATE,
|
||||||
|
TIVIMATE,
|
||||||
|
"com.netflix.ninja",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_a_configured_script_still_wins(self, modules):
|
||||||
|
player, served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={TIVIMATE: "TiviMate"},
|
||||||
|
scripts={"select_source": SCRIPT},
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert served == [
|
||||||
|
(
|
||||||
|
"script",
|
||||||
|
"turn_on",
|
||||||
|
{"entity_id": SCRIPT, "variables": {SCRIPT_VAR_APP_NAME: "TiviMate"}},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert player._manager._protocol.launch_calls == [] # native path untouched
|
||||||
|
|
||||||
|
async def test_an_unknown_name_raises_a_translated_error(self, modules):
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
|
||||||
|
player, _served = _player(modules, app_map={TIVIMATE: "TiviMate"})
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError) as raised:
|
||||||
|
await player.async_select_source("Not An App")
|
||||||
|
|
||||||
|
assert raised.value.translation_domain == "nvidia_shield"
|
||||||
|
assert raised.value.translation_key == "unknown_source"
|
||||||
|
assert raised.value.translation_placeholders == {"source": "Not An App"}
|
||||||
|
assert player._manager._protocol.launch_calls == [] # nothing was sent
|
||||||
|
|
||||||
|
async def test_an_empty_name_is_an_error_too(self, modules):
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
|
||||||
|
player, _served = _player(modules)
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
await player.async_select_source(" ")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdvertisedFeatures:
|
||||||
|
"""HA routes a service by its feature flag, so the flag has to be honest."""
|
||||||
|
|
||||||
|
def test_a_source_list_advertises_select_source(self, modules):
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules)
|
||||||
|
|
||||||
|
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
|
||||||
|
def test_a_script_advertises_it_even_with_no_directory(self, modules, monkeypatch):
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules, scripts={"select_source": SCRIPT})
|
||||||
|
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||||
|
|
||||||
|
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
|
||||||
|
def test_neither_means_it_is_not_offered(self, modules, monkeypatch):
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules)
|
||||||
|
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||||
|
|
||||||
|
assert not player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
|
||||||
|
def test_select_source_is_not_gated_on_the_volume_set_script(self, modules):
|
||||||
|
"""The two script-only commands stay independent."""
|
||||||
|
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||||
|
|
||||||
|
player, _served = _player(modules, scripts={"volume_set": "script.volume"})
|
||||||
|
|
||||||
|
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_SET
|
||||||
|
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizedDirectories:
|
||||||
|
"""t51: a hand-edited map is normalized ONCE, in the shared accessor.
|
||||||
|
|
||||||
|
The two residuals t49 left share one cause — `_app_directory` handed the raw map
|
||||||
|
out, so the Browse tree showed a child with a blank title and the reverse lookup
|
||||||
|
compared the user's padded name against itself. The policy now lives in that one
|
||||||
|
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
|
||||||
|
name) and is pinned here, together with the promise that a well-formed map is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
t61 left two surfaces instead of three: the Browse tree that used to be the
|
||||||
|
second view is gone (apps are sources), so the pins below anchor the same
|
||||||
|
policy on `_app_directory` — the shared accessor itself — and on
|
||||||
|
`source_list`/`_source_package`, which are what a user can still reach.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BLANKED = {
|
||||||
|
"com.blank": "",
|
||||||
|
TIVIMATE: "TiviMate",
|
||||||
|
"com.spaces": " ",
|
||||||
|
"": "Ghost",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_a_blank_entry_never_reaches_the_dropdown(self, modules):
|
||||||
|
"""Blanks are dropped once, in the accessor every offer reads (t49)."""
|
||||||
|
player, _served = _player(modules, app_map=dict(self.BLANKED))
|
||||||
|
|
||||||
|
assert list(player._app_directory) == [TIVIMATE]
|
||||||
|
assert list(player._app_directory.values()) == ["TiviMate"]
|
||||||
|
assert player.source_list == ["TiviMate"]
|
||||||
|
|
||||||
|
def test_a_blank_package_is_dropped_too(self, modules):
|
||||||
|
"""An entry with no package is a row that cannot be launched."""
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(player._app_directory) == [TIVIMATE]
|
||||||
|
assert player.source_list == ["TiviMate"]
|
||||||
|
|
||||||
|
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
|
||||||
|
player, _served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# display and match key are one string, everywhere
|
||||||
|
assert player.source_list == ["TiviMate", "Netflix"]
|
||||||
|
assert list(player._app_directory.values()) == ["TiviMate", "Netflix"]
|
||||||
|
# ...so every spelling the user may type finds the package
|
||||||
|
assert player._source_package("TiviMate") == TIVIMATE
|
||||||
|
assert player._source_package(" TiviMate ") == TIVIMATE
|
||||||
|
assert player._source_package("tivimate") == TIVIMATE
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
async def test_two_packages_with_one_name_keep_a_deterministic_winner(self, modules):
|
||||||
|
"""Both apps stay reachable; the NAME goes to the first in map order."""
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
|
||||||
|
)
|
||||||
|
|
||||||
|
# t61: the directory keeps BOTH entries, each under its own package id;
|
||||||
|
# the removal of the browse tree does not remove the reachability it
|
||||||
|
# used to expose — select_source-by-id is that surface now.
|
||||||
|
assert player._app_directory == {"com.a": "Netflix", "com.b": "Netflix"}
|
||||||
|
assert player._source_package("com.b") == "com.b" # the shadowed package
|
||||||
|
# the dropdown offers the name once, and resolves to the first entry
|
||||||
|
assert player.source_list == ["Netflix"]
|
||||||
|
assert player._source_package("Netflix") == "com.a"
|
||||||
|
|
||||||
|
await player.async_select_source("Netflix")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == ["com.a"]
|
||||||
|
|
||||||
|
# map order decides — the user's order, not chance
|
||||||
|
swapped, _served_swapped = _player(
|
||||||
|
modules, app_map={"com.b": "Netflix", "com.a": "Netflix"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert swapped._source_package("Netflix") == "com.b"
|
||||||
|
|
||||||
|
async def test_a_wellformed_map_passes_through_unchanged(self, modules):
|
||||||
|
"""Stripping touches the EDGES: an inner space is part of the name."""
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player._app_directory == {
|
||||||
|
TIVIMATE: "TiviMate Pro",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
}
|
||||||
|
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||||
|
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseInsensitiveNames:
|
||||||
|
"""t53: one row per NAME, compared case-insensitively — as the lookup does.
|
||||||
|
|
||||||
|
The dropdown and the reverse lookup disagreed about case: two entries called
|
||||||
|
`Dup` and `dup` survived an exact-match dedupe as two rows, while
|
||||||
|
`_source_package` — case-insensitive by design (t47) — resolved BOTH to the
|
||||||
|
first entry. So the second row launched the first entry's app: a row that lies
|
||||||
|
about what it does, and the one shape here the editor can produce by itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_names_differing_only_by_case_are_offered_once(self, modules):
|
||||||
|
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||||
|
|
||||||
|
# one row, keeping the first spelling the user typed
|
||||||
|
assert player.source_list == ["Dup"]
|
||||||
|
|
||||||
|
# ...and the directory still holds both apps, each under its own id, so
|
||||||
|
# the shadowed one stays reachable by its package id (t61: that is what
|
||||||
|
# the removed browse tree used to publish)
|
||||||
|
assert player._app_directory == {"com.a": "Dup", "com.b": "dup"}
|
||||||
|
assert player._source_package("com.b") == "com.b"
|
||||||
|
|
||||||
|
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
|
||||||
|
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||||
|
|
||||||
|
await player.async_select_source(player.source_list[0])
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == ["com.a"] # the row it shows
|
||||||
|
|
||||||
|
async def test_the_shadowed_app_stays_reachable_by_id(self, modules):
|
||||||
|
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||||
|
|
||||||
|
assert player._source_package("com.b") == "com.b"
|
||||||
|
|
||||||
|
await player.async_select_source("com.b")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||||
|
|
||||||
|
# the shadowed NAME still resolves to the one the list offers (first wins)
|
||||||
|
assert player._source_package("dup") == "com.a"
|
||||||
|
|
||||||
|
async def test_the_first_spelling_wins_in_map_order(self, modules):
|
||||||
|
player, _served = _player(modules, app_map={"com.b": "dup", "com.a": "Dup"})
|
||||||
|
|
||||||
|
assert player.source_list == ["dup"]
|
||||||
|
|
||||||
|
await player.async_select_source("dup")
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||||
|
|
||||||
|
async def test_a_mixed_map_offers_each_name_once(self, modules):
|
||||||
|
player, _served = _player(
|
||||||
|
modules,
|
||||||
|
app_map={
|
||||||
|
"com.a": "Dup",
|
||||||
|
"com.b": "dup",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
"com.google.android.youtube.tv": "netflix",
|
||||||
|
TIVIMATE: "TiviMate Pro",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.source_list == ["Dup", "Netflix", "TiviMate Pro"]
|
||||||
|
assert player._source_package("Netflix") == "com.netflix.ninja"
|
||||||
|
|
||||||
|
def test_the_offered_list_never_repeats_a_name(self, modules):
|
||||||
|
"""The invariant behind the fix, over every map shape in this file."""
|
||||||
|
for app_map in (
|
||||||
|
None,
|
||||||
|
{"com.a": "Dup", "com.b": "dup"},
|
||||||
|
{"com.a": "Dup", "com.b": "DUP", "com.c": "dup"},
|
||||||
|
{"com.blank": "", TIVIMATE: " TiviMate ", "com.a": "Netflix", "com.b": "netflix"},
|
||||||
|
):
|
||||||
|
player, _served = _player(modules, app_map=app_map)
|
||||||
|
folded = [name.casefold() for name in player.source_list]
|
||||||
|
|
||||||
|
assert len(folded) == len(set(folded)), app_map
|
||||||
|
assert len(folded) == len(player.source_list)
|
||||||
|
|
||||||
|
def test_a_padded_map_value_is_displayed_verbatim_by_app_name(self, modules):
|
||||||
|
"""The README's boundary, pinned: the list cleans it, app_name does not.
|
||||||
|
|
||||||
|
The name map is the user's own override, so it is used as typed — which is
|
||||||
|
why `source` can be a string that is not in source_list.
|
||||||
|
"""
|
||||||
|
player, _served = _player(
|
||||||
|
modules, app_map={TIVIMATE: " TiviMate "}, foreground=TIVIMATE
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.source_list == ["TiviMate"] # the dropdown cleans it
|
||||||
|
assert player.source == " TiviMate " # this surface shows it as typed
|
||||||
|
assert player.source not in player.source_list
|
||||||
|
|
||||||
|
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
|
||||||
|
"""Blanks are not displayed as nothing — the chain continues past them."""
|
||||||
|
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
|
||||||
|
|
||||||
|
assert player.source == TIVIMATE
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""tests/test_module_coverage.py — nothing in the package may dangle (t41).
|
||||||
|
|
||||||
|
Two defects motivated this file, and both shipped with a green suite:
|
||||||
|
|
||||||
|
1. ``button.py`` imported a helper that had been deleted. No harness ever loaded
|
||||||
|
that file (``entity_stublib.load_entity_modules`` lists the platforms it
|
||||||
|
knows), so the ImportError would have appeared only in a user's log. The fix
|
||||||
|
is exhaustive by construction: the sweep walks the package for ``*.py`` files
|
||||||
|
in a clean interpreter, under the stub harness, and loads every one of them.
|
||||||
|
A new platform is covered the moment the file exists — there is no list to
|
||||||
|
update and no special case.
|
||||||
|
|
||||||
|
2. The drift sweep resolved ``homeassistant.*`` imports only, so it could not
|
||||||
|
see a relative import of a name that no longer exists — the same defect one
|
||||||
|
layer up. ``ha_api_drift.scan_package_imports`` now resolves the package's own
|
||||||
|
imports from the AST, with no Home Assistant and therefore no stub needed.
|
||||||
|
|
||||||
|
Each sweep carries its own red proof, and nothing about the pre-t41 tree supplies
|
||||||
|
it. At ``c588a7c`` ``helpers.py`` still DEFINED ``discover_adb_host``, so there was
|
||||||
|
no broken relative import to find; and that commit's engine predates BOTH passes
|
||||||
|
(its ``ha_api_drift.py`` has no entity-contract check and no package-import check
|
||||||
|
at all), so a real-HA run there is simply clean — exit 0, "no Home-Assistant API
|
||||||
|
drift (13 modules …)", no findings. The proof is therefore built, never borrowed:
|
||||||
|
a copy of the tree with the ``discover_adb_host`` reference actually reverted must
|
||||||
|
fail each sweep, or the check is decoration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ha_api_drift
|
||||||
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR
|
||||||
|
|
||||||
|
TESTS_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = _SHIELD_DIR.parent.parent
|
||||||
|
|
||||||
|
#: Loads every module of a package under the stub harness, in a clean process.
|
||||||
|
#: ``argv``: 1 = this tests directory, 2 = the package directory to sweep.
|
||||||
|
_DRIVER = r"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
tests_dir, package_root = Path(sys.argv[1]), Path(sys.argv[2])
|
||||||
|
sys.path.insert(0, str(tests_dir))
|
||||||
|
sys.path.insert(0, str(package_root))
|
||||||
|
|
||||||
|
import entity_stublib # noqa: E402
|
||||||
|
from conftest import _load_module # noqa: E402
|
||||||
|
|
||||||
|
# conftest eagerly loads a few stdlib-only modules from the REAL tree (it cannot
|
||||||
|
# know about a copied one): point the synthetic package at the tree under test
|
||||||
|
# and drop what it imported, so every relative import resolves inside the copy.
|
||||||
|
sys.modules["nsh"].__path__ = [str(package_root)]
|
||||||
|
for name in [n for n in list(sys.modules) if n.startswith("nsh.")]:
|
||||||
|
del sys.modules[name]
|
||||||
|
|
||||||
|
entity_stublib.install()
|
||||||
|
|
||||||
|
loaded, failures = [], []
|
||||||
|
for path in sorted(package_root.rglob("*.py")):
|
||||||
|
if "__pycache__" in path.parts:
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(package_root)
|
||||||
|
if rel.name == "__init__.py":
|
||||||
|
dotted = ".".join(rel.parent.parts)
|
||||||
|
name = "nsh.init" if dotted in ("", ".") else f"nsh.{dotted}"
|
||||||
|
else:
|
||||||
|
name = "nsh." + ".".join(rel.with_suffix("").parts)
|
||||||
|
search = [str(path.parent)] if rel.name == "__init__.py" else None
|
||||||
|
try:
|
||||||
|
_load_module(name, path, search)
|
||||||
|
except Exception as exc: # noqa: BLE001 - the error IS the finding
|
||||||
|
failures.append(f"{rel}: {type(exc).__name__}: {exc}")
|
||||||
|
else:
|
||||||
|
loaded.append(str(rel))
|
||||||
|
|
||||||
|
for rel in loaded:
|
||||||
|
print(f"OK {rel}")
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAIL {failure}")
|
||||||
|
print(f"SUMMARY loaded={len(loaded)} failed={len(failures)} total={len(loaded) + len(failures)}")
|
||||||
|
sys.exit(1 if failures else 0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _package_modules(root: Path) -> set[str]:
|
||||||
|
"""Every Python file of a package, as the sweep reports them."""
|
||||||
|
return {
|
||||||
|
str(path.relative_to(root))
|
||||||
|
for path in root.rglob("*.py")
|
||||||
|
if "__pycache__" not in path.parts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep(root: Path) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", _DRIVER, str(TESTS_DIR), str(root)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reverted_copy(tmp_path: Path) -> Path:
|
||||||
|
"""A copy of the package where button.py imports the deleted helper again."""
|
||||||
|
target = tmp_path / "nvidia_shield"
|
||||||
|
shutil.copytree(
|
||||||
|
_SHIELD_DIR,
|
||||||
|
target,
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.json", "*.png"),
|
||||||
|
)
|
||||||
|
button = target / "button.py"
|
||||||
|
button.write_text(
|
||||||
|
"from .helpers import discover_adb_host # the reverted reference\n"
|
||||||
|
+ button.read_text(encoding="utf-8"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_module_imports_under_the_stub_harness():
|
||||||
|
"""The whole package loads in a clean interpreter — no module is skipped."""
|
||||||
|
result = _sweep(_SHIELD_DIR)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
assert result.returncode == 0, output
|
||||||
|
reported = {
|
||||||
|
line.split(" ", 1)[1]
|
||||||
|
for line in result.stdout.splitlines()
|
||||||
|
if line.startswith(("OK ", "FAIL "))
|
||||||
|
}
|
||||||
|
assert reported == _package_modules(_SHIELD_DIR), "the sweep skipped or invented a file"
|
||||||
|
assert "failed=0" in result.stdout, output
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||||
|
"""Red proof 1: the exact defect that motivated this check.
|
||||||
|
|
||||||
|
button.py imported ``discover_adb_host`` from helpers after that helper was
|
||||||
|
deleted (t41 removed it with the entity path). The sweep must fail loudly.
|
||||||
|
"""
|
||||||
|
result = _sweep(_reverted_copy(tmp_path))
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
assert result.returncode != 0, output
|
||||||
|
assert "discover_adb_host" in output
|
||||||
|
assert "FAIL button.py" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_relative_import_resolves():
|
||||||
|
"""The same contract, statically: the package's own imports all resolve."""
|
||||||
|
findings = ha_api_drift.scan_package_imports()
|
||||||
|
|
||||||
|
assert findings == [], "\n".join(str(finding) for finding in findings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_relative_import_pass_is_not_vacuous():
|
||||||
|
"""A pass that resolved nothing would be green for the wrong reason."""
|
||||||
|
report = ha_api_drift.Report()
|
||||||
|
|
||||||
|
ha_api_drift._scan_package_imports([_SHIELD_DIR], report)
|
||||||
|
|
||||||
|
assert report.relative_imports >= ha_api_drift.FLOORS["relative_imports"]
|
||||||
|
# and the walk itself reached every module, not only the top level (the
|
||||||
|
# floor sits above the top-level-only count, so a shallow glob trips it)
|
||||||
|
assert len(_package_modules(_SHIELD_DIR)) >= ha_api_drift.FLOORS["modules"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_static_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||||
|
"""Red proof 2: the AST pass sees the same defect, with no Home Assistant."""
|
||||||
|
findings = ha_api_drift.scan_package_imports(_reverted_copy(tmp_path))
|
||||||
|
|
||||||
|
assert [finding.kind for finding in findings] == ["broken-relative-import"]
|
||||||
|
assert "discover_adb_host" in findings[0].detail
|
||||||
|
assert findings[0].path.endswith("button.py")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("source", "expected"),
|
||||||
|
[
|
||||||
|
# a name nobody defines
|
||||||
|
("from .helpers import no_such_helper\n", "broken-relative-import"),
|
||||||
|
# a relative import that leaves the package
|
||||||
|
("from ..sibling import thing\n", "broken-relative-import"),
|
||||||
|
# a submodule import is valid without a binding in __init__
|
||||||
|
("from .ha_adb_common import adb_client\n", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_pass_judges_the_cases_that_matter(tmp_path, source, expected):
|
||||||
|
package = tmp_path / "nvidia_shield"
|
||||||
|
(package / "ha_adb_common").mkdir(parents=True)
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(package / "helpers.py").write_text("def entry_host(entry):\n return ''\n", encoding="utf-8")
|
||||||
|
(package / "ha_adb_common" / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(package / "ha_adb_common" / "adb_client.py").write_text("", encoding="utf-8")
|
||||||
|
(package / "module.py").write_text(source, encoding="utf-8")
|
||||||
|
|
||||||
|
findings = ha_api_drift.scan_package_imports(package)
|
||||||
|
|
||||||
|
assert [finding.kind for finding in findings] == ([expected] if expected else [])
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""tests/test_readme.py — the README's user-facing contracts (t41).
|
||||||
|
|
||||||
|
Documentation drifts silently: the seed example is read by people who then
|
||||||
|
report "the key did nothing". These pins are deliberately textual — no YAML
|
||||||
|
parser dependency — but they check the two things that actually hurt:
|
||||||
|
|
||||||
|
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
|
||||||
|
honours (nothing documented that the integration ignores);
|
||||||
|
* the replacement guide names the integrations and the glue it retires, so the
|
||||||
|
t41 ruling stays visible to users, not only in the changelog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||||
|
|
||||||
|
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
|
||||||
|
# seed block reaches the entry (the rest is the interactive flow's or the
|
||||||
|
# options page's to set).
|
||||||
|
SEEDED_KEYS = {
|
||||||
|
"name",
|
||||||
|
"host",
|
||||||
|
"adb_port",
|
||||||
|
"adb_enabled",
|
||||||
|
"protocol_host", # legacy, still accepted
|
||||||
|
"adb_host", # legacy, still accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def readme_text() -> str:
|
||||||
|
return README.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_example(text: str) -> str:
|
||||||
|
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
|
||||||
|
assert match, "the README has no `nvidia_shield:` example block"
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_example_block_only_uses_seeded_keys(readme_text):
|
||||||
|
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
|
||||||
|
|
||||||
|
assert keys, "the example block declares no keys"
|
||||||
|
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_example_block_documents_the_one_address_field(readme_text):
|
||||||
|
example = _seed_example(readme_text)
|
||||||
|
|
||||||
|
assert "host:" in example
|
||||||
|
# the legacy spellings are still explained, as back-compat
|
||||||
|
assert "protocol_host" in readme_text and "adb_host" in readme_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
||||||
|
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
|
||||||
|
assert needle in readme_text, needle
|
||||||
|
# and it says the UI is the primary path
|
||||||
|
assert "primary path" in readme_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_readme_states_the_on_rule_precisely(readme_text):
|
||||||
|
"""t58 UNIT 0: "no data" was too loose; the README must not still say it."""
|
||||||
|
assert "no source reported a power or playback fact" in readme_text
|
||||||
|
# the four reporting-but-not-playing shapes, named
|
||||||
|
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
|
||||||
|
assert shape in readme_text, shape
|
||||||
|
assert "no source reported anything at all" not in readme_text
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""tests/test_receiver_switch.py — a plain switch as the Receiver (t61).
|
||||||
|
|
||||||
|
The user's request, verbatim: "it would be nice if a receiver could just be a
|
||||||
|
switch, I don't need my volume scripts then because the receiver portion could
|
||||||
|
just make sure the receiver is on if it's a switch". So the playback entry
|
||||||
|
points — ``turn_on``, ``play_media`` and ``select_source`` — ensure a
|
||||||
|
``switch.*`` receiver is ON, and only when it is actually off.
|
||||||
|
|
||||||
|
The four states the contract names are all pinned here:
|
||||||
|
|
||||||
|
* switch off + playback starts → exactly one ``switch.turn_on``;
|
||||||
|
* switch already on → NO call (idempotent, not a toggle);
|
||||||
|
* switch unavailable/unknown/missing/failing → playback continues, nothing raises;
|
||||||
|
* a ``media_player.*`` receiver → never switched; its behaviour is unchanged.
|
||||||
|
|
||||||
|
No real Shield, no real switch and no browser take part: the entity comes from
|
||||||
|
the same stub harness every other entity test uses, the receiver's state is a
|
||||||
|
value in ``FakeStates`` and the service call is recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
from nsh.const import CONF_APP_NAME_MAP, CONF_RECEIVER_ENTITY
|
||||||
|
from test_command_routing import make_player
|
||||||
|
|
||||||
|
TIVIMATE = "ar.tvplayer.tv"
|
||||||
|
SOUNDBAR = "switch.soundbar"
|
||||||
|
AVR = "media_player.avr"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="modules", autouse=True)
|
||||||
|
def scoped_modules():
|
||||||
|
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
|
||||||
|
snapshot = entity_stublib.snapshot_modules()
|
||||||
|
entity_stublib.install()
|
||||||
|
try:
|
||||||
|
yield entity_stublib.load_entity_modules()
|
||||||
|
finally:
|
||||||
|
entity_stublib.restore_modules(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def _player(modules, *, receiver=SOUNDBAR, state=None, app_map=None):
|
||||||
|
"""A player with a Receiver configured (a switch unless told otherwise)."""
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
player._entry.options[CONF_RECEIVER_ENTITY] = receiver
|
||||||
|
if app_map is not None:
|
||||||
|
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||||
|
if state is not None:
|
||||||
|
player.hass.states.set(receiver, state)
|
||||||
|
return player, served
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSwitchReceiverIsEnsuredOn:
|
||||||
|
"""Every playback entry point turns an off switch receiver on — once."""
|
||||||
|
|
||||||
|
async def test_play_media_turns_an_off_receiver_on(self, modules):
|
||||||
|
player, served = _player(modules, state="off")
|
||||||
|
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
|
||||||
|
# the ensure is the ONLY service call: no volume, no toggle, and the
|
||||||
|
# app still launches (which is why the volume scripts are unnecessary)
|
||||||
|
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
async def test_turn_on_turns_an_off_receiver_on(self, modules):
|
||||||
|
player, served = _player(modules, state="off")
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
|
||||||
|
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||||
|
|
||||||
|
async def test_select_source_turns_an_off_receiver_on_once(self, modules):
|
||||||
|
"""select_source dispatches internally, so the switch is read once."""
|
||||||
|
player, served = _player(
|
||||||
|
modules, state="off", app_map={TIVIMATE: "TiviMate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
async def test_an_on_receiver_is_never_called(self, modules):
|
||||||
|
"""Idempotent: every entry point may run, and NOT ONE call goes out."""
|
||||||
|
player, served = _player(modules, state="on")
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
await player.async_select_source("TiviMate")
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_no_volume_service_is_ever_aimed_at_the_receiver(self, modules):
|
||||||
|
"""The user's point: a switch receiver means no volume scripts."""
|
||||||
|
player, served = _player(modules, state="off")
|
||||||
|
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
|
||||||
|
assert [(domain, service) for domain, service, _data in served] == [
|
||||||
|
("switch", "turn_on")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnUnusableReceiverNeverBreaksPlayback:
|
||||||
|
"""A receiver we cannot read, or that fails, is logged — never raised."""
|
||||||
|
|
||||||
|
async def test_an_unavailable_switch_is_left_alone(self, modules):
|
||||||
|
player, served = _player(modules, state="unavailable")
|
||||||
|
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
|
||||||
|
assert served == [] # never guessed at
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE] # still plays
|
||||||
|
|
||||||
|
async def test_an_unknown_state_is_not_guessed_at(self, modules):
|
||||||
|
player, served = _player(modules, state="unknown")
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_a_missing_entity_is_left_alone(self, modules):
|
||||||
|
player, served = _player(modules) # the receiver is not in the machine
|
||||||
|
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
async def test_a_failing_service_call_does_not_stop_playback(self, modules):
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
player, _served = _player(modules, state="off")
|
||||||
|
|
||||||
|
class _BrokenServices:
|
||||||
|
async def async_call(self, domain, service, data=None, **kwargs):
|
||||||
|
raise HomeAssistantError("the switch entity is gone")
|
||||||
|
|
||||||
|
player.hass.services = _BrokenServices()
|
||||||
|
|
||||||
|
await player.async_play_media("app", TIVIMATE) # must not raise
|
||||||
|
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAMediaPlayerReceiverIsUntouched:
|
||||||
|
"""An AVR stays an AVR: input selection and enrichment, never a switch call."""
|
||||||
|
|
||||||
|
async def test_a_media_player_receiver_is_never_switched(self, modules):
|
||||||
|
player, served = _player(modules, receiver=AVR, state="off")
|
||||||
|
player.hass.states.set(AVR, "idle")
|
||||||
|
|
||||||
|
await player.async_turn_on()
|
||||||
|
await player.async_play_media("app", TIVIMATE)
|
||||||
|
|
||||||
|
assert served == []
|
||||||
|
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||||
|
|
||||||
|
async def test_the_receiver_option_still_reaches_the_attributes(self, modules):
|
||||||
|
from nsh.const import ATTR_MEDIA_DAG_RECEIVER_ENTITY
|
||||||
|
|
||||||
|
player, _served = _player(modules, receiver=AVR)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
player.extra_state_attributes[ATTR_MEDIA_DAG_RECEIVER_ENTITY] == AVR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnIdleShieldLeavesTheReceiverAlone:
|
||||||
|
"""Switching the receiver on is a PLAYBACK action, not a synchronisation."""
|
||||||
|
|
||||||
|
async def test_reading_state_and_attributes_calls_nothing(self, modules):
|
||||||
|
player, served = _player(modules, state="off")
|
||||||
|
|
||||||
|
assert player.state is not None # a state read
|
||||||
|
assert isinstance(player.extra_state_attributes, dict) # what the frontend reads
|
||||||
|
player._on_manager_update() # the connection manager pushed an update
|
||||||
|
|
||||||
|
assert served == []
|
||||||
@@ -0,0 +1,654 @@
|
|||||||
|
"""tests/test_release_versioning.py — the release version decision (t42).
|
||||||
|
|
||||||
|
Why this file exists: the version the workflow picks decides whether HACS ever
|
||||||
|
offers a user the update. Every wrong answer is silent — the tag and the release
|
||||||
|
still appear, only the manifest never moves — and the real evidence is in the
|
||||||
|
repo history: v2026.10.09 was published while master's manifest still read
|
||||||
|
2026.10.08. The decision therefore lives in ``scripts/determine-release-version.sh``
|
||||||
|
and every branch below is driven directly, rather than trusted to a CI run.
|
||||||
|
|
||||||
|
The four scenarios the task names, plus the boundary cases that made the old
|
||||||
|
inline shell wrong:
|
||||||
|
|
||||||
|
(a) manifest version untagged -> manifest wins (BUMP=no)
|
||||||
|
(b) manifest tagged elsewhere, date free -> date
|
||||||
|
(c) manifest AND date tagged -> same-day CALVER suffix, lowest free N
|
||||||
|
(d) the commit is already released -> RELEASE=no, explicit no-op signal
|
||||||
|
|
||||||
|
The script is also the only place that reads the clock, and it reads it in UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT = REPO_ROOT / "scripts" / "determine-release-version.sh"
|
||||||
|
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
||||||
|
MANIFEST = REPO_ROOT / "custom_components" / "nvidia_shield" / "manifest.json"
|
||||||
|
|
||||||
|
HEAD = "a" * 40
|
||||||
|
OTHER = "b" * 40
|
||||||
|
TODAY = "2026.10.11"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
manifest_version: str,
|
||||||
|
tags=(),
|
||||||
|
today: str | None = TODAY,
|
||||||
|
head: str | None = HEAD,
|
||||||
|
env: dict | None = None,
|
||||||
|
):
|
||||||
|
"""Run the resolver against a fixture manifest and tag list."""
|
||||||
|
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest = tmp_path / "manifest.json"
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps({"domain": "nvidia_shield", "version": manifest_version}) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tag_list = tmp_path / "tags"
|
||||||
|
tag_list.write_text(
|
||||||
|
"".join(f"{tag} {sha}\n" for tag, sha in tags), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
argv = [
|
||||||
|
"bash",
|
||||||
|
str(SCRIPT),
|
||||||
|
"--manifest",
|
||||||
|
str(manifest),
|
||||||
|
"--tags-from",
|
||||||
|
str(tag_list),
|
||||||
|
]
|
||||||
|
if today is not None:
|
||||||
|
argv += ["--today", today]
|
||||||
|
if head is not None:
|
||||||
|
argv += ["--head", head]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
argv,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, **(env or {})},
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
values = dict(
|
||||||
|
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||||
|
)
|
||||||
|
return result, values
|
||||||
|
|
||||||
|
|
||||||
|
# ── (a) the manifest wins while its version is untagged ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_untagged_manifest_version_is_released_as_is(tmp_path):
|
||||||
|
result, values = _resolve(
|
||||||
|
tmp_path, manifest_version="2026.10.11", tags=[("v2026.10.09", OTHER)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert values["VERSION"] == "2026.10.11"
|
||||||
|
assert values["TAG"] == "v2026.10.11"
|
||||||
|
assert values["SOURCE"] == "manifest"
|
||||||
|
assert values["RELEASE"] == "yes"
|
||||||
|
# the manifest already says it: no write-back commit, so the tag lands on
|
||||||
|
# the pushed commit itself
|
||||||
|
assert values["BUMP"] == "no"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_leading_v_in_the_manifest_is_tolerated(tmp_path):
|
||||||
|
"""The tag adds the `v`; a hand-written one must not become the version."""
|
||||||
|
_result, values = _resolve(tmp_path, manifest_version="v2026.10.11")
|
||||||
|
|
||||||
|
assert values["VERSION"] == "2026.10.11"
|
||||||
|
assert values["TAG"] == "v2026.10.11"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (b) once the manifest version is tagged, the date takes over ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tagged_manifest_version_falls_back_to_the_utc_date(tmp_path):
|
||||||
|
result, values = _resolve(
|
||||||
|
tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert values["VERSION"] == TODAY
|
||||||
|
assert values["SOURCE"] == "date"
|
||||||
|
assert values["RELEASE"] == "yes"
|
||||||
|
# the manifest still says the old version: the workflow must write the new
|
||||||
|
# one back, or HACS keeps comparing against 2026.10.09 forever
|
||||||
|
assert values["BUMP"] == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (c) a second release in one UTC day ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tagged_date_falls_back_to_a_same_day_suffix(tmp_path):
|
||||||
|
result, values = _resolve(
|
||||||
|
tmp_path,
|
||||||
|
manifest_version="2026.10.09",
|
||||||
|
tags=[("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert values["VERSION"] == f"{TODAY}.2"
|
||||||
|
assert values["SOURCE"] == "same-day"
|
||||||
|
assert values["RELEASE"] == "yes"
|
||||||
|
assert values["BUMP"] == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_suffix_is_the_lowest_untagged_n_from_two(tmp_path):
|
||||||
|
"""Not "highest + 1": a gap is filled, so a failed run does not burn a number."""
|
||||||
|
_result, values = _resolve(
|
||||||
|
tmp_path,
|
||||||
|
manifest_version="2026.10.09",
|
||||||
|
tags=[
|
||||||
|
("v2026.10.09", OTHER),
|
||||||
|
(f"v{TODAY}", OTHER),
|
||||||
|
(f"v{TODAY}.2", OTHER),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert values["VERSION"] == f"{TODAY}.3"
|
||||||
|
|
||||||
|
_result, values = _resolve(
|
||||||
|
tmp_path / "second",
|
||||||
|
manifest_version="2026.10.09",
|
||||||
|
tags=[
|
||||||
|
("v2026.10.09", OTHER),
|
||||||
|
(f"v{TODAY}", OTHER),
|
||||||
|
(f"v{TODAY}.2", OTHER),
|
||||||
|
(f"v{TODAY}.3", OTHER),
|
||||||
|
(f"v{TODAY}.5", OTHER), # a gap at .4 must be reused
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert values["VERSION"] == f"{TODAY}.4"
|
||||||
|
|
||||||
|
|
||||||
|
# ── (d) nothing to do ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_already_released_commit_is_an_explicit_no_op(tmp_path):
|
||||||
|
"""A re-run (manual dispatch, a retried job) must not invent a new version."""
|
||||||
|
result, values = _resolve(
|
||||||
|
tmp_path,
|
||||||
|
manifest_version="2026.10.09",
|
||||||
|
tags=[("v2026.10.09", HEAD)], # this very commit is the release
|
||||||
|
head=HEAD,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert values["RELEASE"] == "no"
|
||||||
|
assert values["VERSION"] == "2026.10.09"
|
||||||
|
assert values["SOURCE"] == "released"
|
||||||
|
assert values["BUMP"] == "no"
|
||||||
|
assert "nothing to release" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_release_is_never_planned_for_a_tag_that_exists(tmp_path):
|
||||||
|
"""The property the backstop defends, over a spread of tag sets.
|
||||||
|
|
||||||
|
RELEASE=yes must always mean "this version is not tagged yet": that is what
|
||||||
|
makes the workflow's tag step safe without re-checking.
|
||||||
|
"""
|
||||||
|
scenarios = [
|
||||||
|
("2026.10.09", []),
|
||||||
|
("2026.10.09", [("v2026.10.09", OTHER)]),
|
||||||
|
("2026.10.09", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||||
|
("2026.10.11", [("v2026.10.08", OTHER)]),
|
||||||
|
(f"{TODAY}.2", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||||
|
("2026.10.09", [("v2026.10.09", HEAD)]), # already released
|
||||||
|
]
|
||||||
|
|
||||||
|
for index, (manifest_version, tags) in enumerate(scenarios):
|
||||||
|
_result, values = _resolve(
|
||||||
|
tmp_path / f"case{index}", manifest_version=manifest_version, tags=tags
|
||||||
|
)
|
||||||
|
tagged = {tag for tag, _sha in tags}
|
||||||
|
|
||||||
|
if values["RELEASE"] == "yes":
|
||||||
|
assert values["TAG"] not in tagged, (manifest_version, tags, values)
|
||||||
|
else:
|
||||||
|
assert values["SOURCE"] in {"released", "tagged"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── (e) UTC determinism ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_date_is_utc_whatever_the_runner_timezone_is(tmp_path):
|
||||||
|
"""A version number must not depend on where the job happened to run."""
|
||||||
|
tags = [("v2026.09.30", OTHER)] # the manifest version is tagged -> date
|
||||||
|
runs = []
|
||||||
|
for timezone in ("Pacific/Kiritimati", "Etc/GMT+12", "America/Anchorage"):
|
||||||
|
_result, values = _resolve(
|
||||||
|
tmp_path / timezone.replace("/", "_"),
|
||||||
|
manifest_version="2026.09.30",
|
||||||
|
tags=tags,
|
||||||
|
today=None, # exercise the real clock
|
||||||
|
env={"TZ": timezone},
|
||||||
|
)
|
||||||
|
runs.append(values["VERSION"])
|
||||||
|
|
||||||
|
utc_today = subprocess.run(
|
||||||
|
["date", "-u", "+%Y.%m.%d"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
assert len(set(runs)) == 1, runs
|
||||||
|
assert runs[0] == utc_today
|
||||||
|
|
||||||
|
|
||||||
|
# ── usage and error handling ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_manifest_fails_loudly(tmp_path):
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(SCRIPT), "--manifest", str(tmp_path / "nope.json"), "--today", TODAY],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "manifest not found" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_calver_manifest_version_fails_loudly(tmp_path):
|
||||||
|
result, _values = _resolve(tmp_path, manifest_version="1.2.3")
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "not YYYY.MM.DD" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_argument_is_a_usage_error(tmp_path):
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(SCRIPT), "--nonsense"], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "unknown argument" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_output_receives_the_same_values(tmp_path):
|
||||||
|
"""The workflow consumes $GITHUB_OUTPUT, so the script must fill it too."""
|
||||||
|
output = tmp_path / "github_output"
|
||||||
|
output.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
result, values = _resolve(
|
||||||
|
tmp_path, manifest_version="2026.10.11", env={"GITHUB_OUTPUT": str(output)}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
written = dict(
|
||||||
|
line.split("=", 1)
|
||||||
|
for line in output.read_text(encoding="utf-8").splitlines()
|
||||||
|
if "=" in line
|
||||||
|
)
|
||||||
|
assert written == values
|
||||||
|
assert set(written) == {"VERSION", "TAG", "SOURCE", "BUMP", "RELEASE"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── the workflow contract (textual: the fast suite has no YAML parser) ───────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def workflow_text() -> str:
|
||||||
|
return WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_workflow_uses_the_tested_script(workflow_text):
|
||||||
|
assert "scripts/determine-release-version.sh" in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_workflow_writes_the_manifest_back_and_tags_that_commit(workflow_text):
|
||||||
|
# the exact commit message the task pins, with the skip-ci marker
|
||||||
|
assert "chore(release): v${VERSION} [skip ci]" in workflow_text
|
||||||
|
# the push happens before the tag, so the tag names the bumped commit
|
||||||
|
assert "git push origin HEAD:master" in workflow_text
|
||||||
|
assert 'git push origin "refs/tags/${TAG}"' in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rejected_bump_push_fails_with_a_manual_instruction(workflow_text):
|
||||||
|
assert "could not push" in workflow_text
|
||||||
|
assert "bump" in workflow_text.lower() and "manually" in workflow_text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_workflow_verifies_the_pushed_manifest_before_publishing(workflow_text):
|
||||||
|
assert "origin/master:${MANIFEST}" in workflow_text
|
||||||
|
assert "inconsistent" in workflow_text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bot_commit_cannot_trigger_another_release(workflow_text):
|
||||||
|
# two layers: the conventional skip-ci marker on the commit, and a job-level
|
||||||
|
# guard that re-checks the message (the guard is what must hold even if a
|
||||||
|
# future Gitea stops honouring the marker)
|
||||||
|
assert "[skip ci]" in workflow_text
|
||||||
|
assert "github.event.head_commit.message" in workflow_text
|
||||||
|
assert "workflow_dispatch" in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_skip_guard_matches_the_subject_not_the_whole_message(workflow_text):
|
||||||
|
"""t44 R3: `contains(message, '[skip ci]')` skipped a release silently.
|
||||||
|
|
||||||
|
A developer commit whose BODY mentions the marker is not a release commit, so
|
||||||
|
the guard is anchored to the release bot's subject prefix instead.
|
||||||
|
"""
|
||||||
|
assert "!startsWith(github.event.head_commit.message || '', 'chore(release):')" in (
|
||||||
|
workflow_text
|
||||||
|
)
|
||||||
|
assert "!contains(github.event.head_commit.message" not in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_release_notes_exclude_the_write_back_commit(workflow_text):
|
||||||
|
"""t44 R1: with a write-back the tag IS the bot commit, so notes must skip it."""
|
||||||
|
assert 'if [ "$BUMP" = "yes" ]; then' in workflow_text
|
||||||
|
assert 'NOTES_END="${TAG}^"' in workflow_text
|
||||||
|
assert 'RANGE="${PREV}..${NOTES_END}"' in workflow_text
|
||||||
|
assert 'BUMP: ${{ steps.version.outputs.BUMP }}' in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_preserved_release_behaviour_is_still_there(workflow_text):
|
||||||
|
# one release at a time, never cancelled mid-flight
|
||||||
|
assert "group: release-master" in workflow_text
|
||||||
|
assert "cancel-in-progress: false" in workflow_text
|
||||||
|
# notes from the commit range
|
||||||
|
assert "git log --pretty=format" in workflow_text
|
||||||
|
# the 409-tolerant publish path
|
||||||
|
assert "409 is" in workflow_text or "409" in workflow_text
|
||||||
|
assert "/releases/tags/" in workflow_text
|
||||||
|
# the zip asset
|
||||||
|
assert "attachment=@${ZIP_NAME}" in workflow_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_script_reads_the_manifest_the_workflow_bumps():
|
||||||
|
"""One path, so a moved manifest cannot silently release a stale version."""
|
||||||
|
assert MANIFEST.is_file()
|
||||||
|
assert "custom_components/nvidia_shield/manifest.json" in SCRIPT.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
manifest_version = json.loads(MANIFEST.read_text(encoding="utf-8"))["version"]
|
||||||
|
# the script rejects anything else, so a typo here would fail every release
|
||||||
|
assert re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?", manifest_version)
|
||||||
|
|
||||||
|
|
||||||
|
# ── t44 3c: real repositories, and the workflow's own steps by execution ─────
|
||||||
|
|
||||||
|
MANIFEST_REL = "custom_components/nvidia_shield/manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo, *args) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], cwd=repo, capture_output=True, text=True, check=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _repo(tmp_path, *, version="2026.10.09", commits=("a feature commit",)):
|
||||||
|
"""A real repository: the resolver's tag lookups really are git commands."""
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir(parents=True)
|
||||||
|
_git(repo, "init", "-q", "-b", "master")
|
||||||
|
_git(repo, "config", "user.email", "dev@example.com")
|
||||||
|
_git(repo, "config", "user.name", "dev")
|
||||||
|
manifest = repo / MANIFEST_REL
|
||||||
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for index, subject in enumerate(commits):
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"domain": "nvidia_shield", "version": version, "note": index}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
_git(repo, "commit", "-qm", subject)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_in(repo, *, today=TODAY):
|
||||||
|
"""Run the resolver inside a real repo — no tag fixtures, the real git paths."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(SCRIPT), "--manifest", MANIFEST_REL, "--today", today],
|
||||||
|
cwd=repo, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
values = dict(
|
||||||
|
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||||
|
)
|
||||||
|
return result, values
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealRepositories:
|
||||||
|
"""The tagged-version branches against real git (t44 3c).
|
||||||
|
|
||||||
|
The fixture-based tests above never exercise `refs/tags/X^{commit}`, and that
|
||||||
|
dereference is the whole reason an annotated tag is recognised: for one,
|
||||||
|
`git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a comparison
|
||||||
|
that forgot to dereference would miss a released commit entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_an_annotated_tag_on_head_is_already_released(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path)
|
||||||
|
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||||
|
|
||||||
|
result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert values["SOURCE"] == "released"
|
||||||
|
assert values["RELEASE"] == "no"
|
||||||
|
assert "nothing to release" in result.stderr
|
||||||
|
# premise: the tag object really is not the commit
|
||||||
|
assert _git(repo, "rev-parse", "v2026.10.09").strip() != _git(
|
||||||
|
repo, "rev-parse", "HEAD"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
def test_a_lightweight_tag_on_head_is_also_already_released(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path)
|
||||||
|
_git(repo, "tag", "v2026.10.09")
|
||||||
|
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert values["SOURCE"] == "released"
|
||||||
|
assert values["RELEASE"] == "no"
|
||||||
|
|
||||||
|
def test_a_tag_on_an_older_commit_falls_back_to_the_date(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path, commits=("the released work", "later work"))
|
||||||
|
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09", "HEAD~1")
|
||||||
|
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert values["VERSION"] == TODAY
|
||||||
|
assert values["SOURCE"] == "date"
|
||||||
|
assert values["BUMP"] == "yes"
|
||||||
|
assert values["RELEASE"] == "yes"
|
||||||
|
|
||||||
|
def test_no_tags_at_all_means_the_manifest_wins(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path, version="2026.10.10")
|
||||||
|
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert values["VERSION"] == "2026.10.10"
|
||||||
|
assert values["SOURCE"] == "manifest"
|
||||||
|
assert values["BUMP"] == "no"
|
||||||
|
|
||||||
|
def test_the_same_day_scan_walks_real_tags(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path, commits=("released", "more"))
|
||||||
|
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.2"):
|
||||||
|
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||||
|
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert values["VERSION"] == f"{TODAY}.3"
|
||||||
|
assert values["SOURCE"] == "same-day"
|
||||||
|
|
||||||
|
def test_a_gap_in_the_suffixes_is_reused(self, tmp_path):
|
||||||
|
repo = _repo(tmp_path, commits=("released", "more"))
|
||||||
|
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.3"):
|
||||||
|
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||||
|
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
|
||||||
|
assert values["VERSION"] == f"{TODAY}.2"
|
||||||
|
|
||||||
|
def test_the_guard_has_a_script_side_layer(self, tmp_path):
|
||||||
|
"""Even with the job guard bypassed, a released commit releases nothing.
|
||||||
|
|
||||||
|
The job-level `if:` cannot be executed here (no Gitea runner), but the
|
||||||
|
decision it protects can: this is the layer that makes a bypass harmless.
|
||||||
|
"""
|
||||||
|
repo = _repo(tmp_path)
|
||||||
|
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||||
|
# a re-run of the same push, guard or no guard
|
||||||
|
for _attempt in range(2):
|
||||||
|
_result, values = _resolve_in(repo)
|
||||||
|
assert values["RELEASE"] == "no"
|
||||||
|
assert values["BUMP"] == "no"
|
||||||
|
|
||||||
|
|
||||||
|
def _step_script(name: str) -> str:
|
||||||
|
"""The `run:` body of one workflow step, read by hand.
|
||||||
|
|
||||||
|
The fast suite has no YAML parser (the verify command parses the file with
|
||||||
|
pyyaml instead), and executing these steps is worth more than matching their
|
||||||
|
text, so the block is taken verbatim and dedented.
|
||||||
|
"""
|
||||||
|
lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
|
||||||
|
start = next(i for i, line in enumerate(lines) if line.strip() == f"- name: {name}")
|
||||||
|
run_at = next(
|
||||||
|
i for i in range(start, len(lines)) if lines[i].strip() in {"run: |", "run: >-"}
|
||||||
|
)
|
||||||
|
indent = len(lines[run_at + 1]) - len(lines[run_at + 1].lstrip())
|
||||||
|
body = []
|
||||||
|
for line in lines[run_at + 1 :]:
|
||||||
|
if line.strip() and not line.startswith(" " * indent):
|
||||||
|
break
|
||||||
|
body.append(line[indent:] if len(line) >= indent else "")
|
||||||
|
return "\n".join(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_step(name: str, cwd, env):
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", "-euo", "pipefail", "-c", _step_script(name)],
|
||||||
|
cwd=cwd, capture_output=True, text=True, env={**os.environ, **env},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowStepsByExecution:
|
||||||
|
"""Run the workflow's own steps against a bare origin (t44 3c).
|
||||||
|
|
||||||
|
Textual pins cannot tell whether the bump lands on the commit the tag names;
|
||||||
|
these do, and they are what the dry run in the task output was.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VERSION = "2026.10.11"
|
||||||
|
TAG = "v2026.10.11"
|
||||||
|
|
||||||
|
def _clone(self, tmp_path, *, protect=False):
|
||||||
|
origin = tmp_path / "origin.git"
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "--bare", "-q", "-b", "master", str(origin)], check=True
|
||||||
|
)
|
||||||
|
work = tmp_path / "work"
|
||||||
|
subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True)
|
||||||
|
_git(work, "config", "user.email", "dev@example.com")
|
||||||
|
_git(work, "config", "user.name", "dev")
|
||||||
|
manifest = work / MANIFEST_REL
|
||||||
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest.write_text(
|
||||||
|
json.dumps({"domain": "nvidia_shield", "version": "2026.10.08"}) + "\n"
|
||||||
|
)
|
||||||
|
_git(work, "add", "-A")
|
||||||
|
_git(work, "commit", "-qm", "a feature commit")
|
||||||
|
_git(work, "push", "-q", "origin", "master")
|
||||||
|
if protect:
|
||||||
|
hook = origin / "hooks" / "pre-receive"
|
||||||
|
hook.write_text("#!/bin/sh\necho 'protected branch' >&2\nexit 1\n")
|
||||||
|
hook.chmod(0o755)
|
||||||
|
return work
|
||||||
|
|
||||||
|
def _env(self):
|
||||||
|
return {
|
||||||
|
"MANIFEST": MANIFEST_REL,
|
||||||
|
"VERSION": self.VERSION,
|
||||||
|
"TAG": self.TAG,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_the_bump_verify_and_tag_steps_agree_on_one_commit(self, tmp_path):
|
||||||
|
work = self._clone(tmp_path)
|
||||||
|
|
||||||
|
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||||
|
assert bump.returncode == 0, bump.stderr
|
||||||
|
assert _git(work, "log", "-1", "--pretty=%s").strip() == (
|
||||||
|
f"chore(release): {self.TAG} [skip ci]"
|
||||||
|
)
|
||||||
|
|
||||||
|
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||||
|
assert verify.returncode == 0, verify.stderr
|
||||||
|
assert f"matching {self.TAG}" in verify.stdout
|
||||||
|
|
||||||
|
tag = _run_step("Create tag", work, self._env())
|
||||||
|
assert tag.returncode == 0, tag.stderr
|
||||||
|
|
||||||
|
head = _git(work, "rev-parse", "HEAD").strip()
|
||||||
|
assert _git(work, "rev-parse", f"{self.TAG}^{{commit}}").strip() == head
|
||||||
|
tagged_manifest = json.loads(_git(work, "show", f"{self.TAG}:{MANIFEST_REL}"))
|
||||||
|
assert tagged_manifest["version"] == self.VERSION
|
||||||
|
assert _git(work, "ls-remote", "--tags", "origin", self.TAG).split()[1] == (
|
||||||
|
f"refs/tags/{self.TAG}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_verify_step_refuses_an_inconsistent_manifest(self, tmp_path):
|
||||||
|
work = self._clone(tmp_path)
|
||||||
|
_run_step("Write the version back into the manifest", work, self._env())
|
||||||
|
# someone else's release lands on master before this one is verified
|
||||||
|
(work / MANIFEST_REL).write_text(
|
||||||
|
json.dumps({"domain": "nvidia_shield", "version": "2026.10.99"}) + "\n"
|
||||||
|
)
|
||||||
|
_git(work, "add", "-A")
|
||||||
|
_git(work, "commit", "-qm", "a competing release")
|
||||||
|
_git(work, "push", "-q", "origin", "master")
|
||||||
|
|
||||||
|
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||||
|
|
||||||
|
assert verify.returncode == 1
|
||||||
|
assert "inconsistent" in verify.stderr
|
||||||
|
assert "2026.10.99" in verify.stderr
|
||||||
|
|
||||||
|
def test_the_notes_range_describes_the_developers_commits(self, tmp_path):
|
||||||
|
"""t44 R1, executed: the range the notes use must not open with the bot commit.
|
||||||
|
|
||||||
|
With a write-back the tag IS the bot's commit, so `PREV..TAG` (the range
|
||||||
|
before this fix) published "- chore(release): vX [skip ci]" as the first
|
||||||
|
line of every release. The range is built from ``BUMP``, and this runs both
|
||||||
|
halves through real git.
|
||||||
|
"""
|
||||||
|
work = self._clone(tmp_path)
|
||||||
|
_git(work, "tag", "-a", "v2026.10.08", "-m", "previous release")
|
||||||
|
_git(work, "commit", "--allow-empty", "-qm", "feat: the real change")
|
||||||
|
_run_step("Write the version back into the manifest", work, self._env())
|
||||||
|
_run_step("Create tag", work, self._env())
|
||||||
|
|
||||||
|
def subjects(rev_range):
|
||||||
|
return _git(work, "log", "--pretty=format:- %s", rev_range)
|
||||||
|
|
||||||
|
notes_end = f"{self.TAG}^" # BUMP=yes: the write-back commit is skipped
|
||||||
|
prev = _git(work, "describe", "--tags", "--abbrev=0", f"{notes_end}^").strip()
|
||||||
|
|
||||||
|
assert prev == "v2026.10.08"
|
||||||
|
assert subjects(f"{prev}..{notes_end}") == "- feat: the real change"
|
||||||
|
# ...and the range this replaced really did include the bot's own subject
|
||||||
|
assert "- chore(release):" in subjects(f"{prev}..{self.TAG}")
|
||||||
|
|
||||||
|
def test_a_rejected_bump_push_fails_and_tags_nothing(self, tmp_path):
|
||||||
|
work = self._clone(tmp_path, protect=True)
|
||||||
|
|
||||||
|
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||||
|
|
||||||
|
assert bump.returncode == 1
|
||||||
|
assert "could not push" in bump.stderr
|
||||||
|
assert "manually" in bump.stderr
|
||||||
|
assert MANIFEST_REL in bump.stderr
|
||||||
|
assert _git(work, "tag", "-l").strip() == ""
|
||||||
+623
-137
File diff suppressed because it is too large.
Load diff
@@ -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