Compare commits
171
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 | ||
|
|
79faa32b68 | ||
|
|
e51545262e | ||
|
|
9b3142874f | ||
|
|
27715f6932 | ||
|
|
6c925c5241 | ||
|
|
3587747c72 | ||
|
|
2e63135745 | ||
|
|
67174b8536 | ||
|
|
b442440ccf | ||
|
|
697e3ba307 | ||
|
|
f863496ce5 | ||
|
|
07cb73fbb7 | ||
|
|
0ecee66953 | ||
|
|
a3b397920e | ||
|
|
3d026ae60e | ||
|
|
4661e52041 | ||
|
|
c57a2e2ff2 | ||
|
|
2e6dfd8981 | ||
|
|
cd0e634a76 | ||
|
|
80e9a691bc | ||
|
|
17648f43c9 | ||
|
|
f03ef1f531 | ||
|
|
b37a14b920 | ||
|
|
c44c2c2170 | ||
|
|
326f02cb59 | ||
|
|
034cb5c83d | ||
|
|
4222027602 | ||
|
|
f4d750ff92 | ||
|
|
61c7ac99a3 | ||
|
|
1dbb695cec | ||
|
|
8b4fea9a5c | ||
|
|
ce90f59f8e | ||
|
|
3b8e97fe5b | ||
|
|
392553ee26 | ||
|
|
f58be9ed05 | ||
|
|
bc457baae7 | ||
|
|
1f0a4b2021 | ||
|
|
aa4ee7ed62 | ||
|
|
bb2f787fcd | ||
|
|
8885ddd5b5 | ||
|
|
f1e49a661b | ||
|
|
34d09e9f70 | ||
|
|
d35b61078d | ||
|
|
a65c893339 | ||
|
|
5f8c3290cc | ||
|
|
8462ad494c | ||
|
|
b451638c65 | ||
|
|
f071d540bd | ||
|
|
728b7ccbf9 | ||
|
|
61aca9d413 | ||
|
|
906126b0da | ||
|
|
d57c4eaa40 | ||
|
|
b5fc0dacf4 | ||
|
|
e37a3be75f | ||
|
|
a4e7a285d4 | ||
|
|
9a1e1300dd | ||
|
|
49cf402bbe | ||
|
|
27cc69b1d3 | ||
|
|
d07820cde8 | ||
|
|
75fff11d04 | ||
|
|
9c409b3805 | ||
|
|
24981d84a8 | ||
|
|
48c30463ec | ||
|
|
0da84a5a6e | ||
|
|
93b766ae97 | ||
|
|
0a7dd887d7 | ||
|
|
63f76f5328 | ||
|
|
ec2db2ae58 | ||
|
|
4779c20407 | ||
|
|
2ade5091c4 | ||
|
|
4269e233b8 | ||
|
|
37164b6f24 | ||
|
|
a72cf57494 | ||
|
|
a1e671ac6a | ||
|
|
b91a4242ba | ||
|
|
f6a37bdcd5 | ||
|
|
db8712db1a | ||
|
|
8f27618e3d | ||
|
|
4702c9534c | ||
|
|
ce82127a40 | ||
|
|
6b4227cb40 | ||
|
|
d3afda0a25 | ||
|
|
359b96ef9c | ||
|
|
a2f1948a88 |
No files matched your search
+114
-38
@@ -1,10 +1,19 @@
|
||||
# Publish a date-based release every time master moves.
|
||||
# Publish a date-based release every time master moves — and keep HACS able to
|
||||
# see every one of them (t42).
|
||||
#
|
||||
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
|
||||
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
|
||||
# manifest to force a specific release, e.g. after a meaningful change); otherwise
|
||||
# the version is today's date (UTC), so a plain push to master publishes the
|
||||
# day's release. Repeated pushes on the same day are deduped by tag existence.
|
||||
# Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N)
|
||||
# for further releases in the same UTC day. The decision lives in
|
||||
# scripts/determine-release-version.sh so it can be tested locally:
|
||||
# the manifest `version` is untagged -> that version (bump it to force one)
|
||||
# else today's UTC date is untagged -> vYYYY.MM.DD
|
||||
# else -> vYYYY.MM.DD.N, lowest untagged N >= 2
|
||||
#
|
||||
# Why the write-back below exists: the resolved version is written into
|
||||
# custom_components/nvidia_shield/manifest.json and committed BEFORE the tag, so
|
||||
# the released artifact and its tag always agree. Without it HACS compares the
|
||||
# installed version against a manifest that never moves — v2026.10.09 was
|
||||
# published while master's manifest still said 2026.10.08, so the second release
|
||||
# of that day was offered to nobody.
|
||||
#
|
||||
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
|
||||
# nvidia_shield/) so the integration is installable manually from any release.
|
||||
@@ -28,15 +37,28 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# The job token pushes the tag and creates the release.
|
||||
# The job token pushes the manifest commit and the tag, and creates the release.
|
||||
contents: write
|
||||
|
||||
env:
|
||||
ZIP_NAME: nvidia_shield.zip
|
||||
MANIFEST: custom_components/nvidia_shield/manifest.json
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# t42/t44 R3: this job pushes the write-back commit itself, and a run starting
|
||||
# from that commit would release the same version twice. The guard is anchored to
|
||||
# the release commit's SUBJECT (`chore(release): …`), not to the `[skip ci]`
|
||||
# marker anywhere in the message: a developer commit whose body happens to
|
||||
# mention [skip ci] must still release — with contains() it silently did not.
|
||||
# The marker stays in the commit message because it is the convention Gitea
|
||||
# itself may honour to skip the run outright (README documents that platform
|
||||
# layer, and that the job guard is a no-op there rather than the trigger).
|
||||
# workflow_dispatch has no head_commit, hence the first clause.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
!startsWith(github.event.head_commit.message || '', 'chore(release):')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -45,61 +67,115 @@ jobs:
|
||||
ref: master
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Determine release version
|
||||
- name: Determine the release version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
|
||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||
# An unreleased manifest version wins over today's date.
|
||||
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
|
||||
VERSION="${DATE_VERSION}"
|
||||
else
|
||||
VERSION="${MANIFEST_VERSION}"
|
||||
fi
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "v${VERSION} is already tagged — nothing to release"
|
||||
echo "exists=yes" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "releasing v${VERSION}"
|
||||
echo "exists=no" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT
|
||||
# is set inside a step, to this job's outputs too. RELEASE=no means the
|
||||
# version already exists: tag and publish nothing.
|
||||
scripts/determine-release-version.sh
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
- name: Write the version back into the manifest
|
||||
id: bump
|
||||
if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
|
||||
|
||||
python3 - "$MANIFEST" "$VERSION" <<'PY'
|
||||
import json, pathlib, sys
|
||||
path, version = pathlib.Path(sys.argv[1]), sys.argv[2]
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["version"] = version
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
git add "$MANIFEST"
|
||||
git commit -m "chore(release): v${VERSION} [skip ci]"
|
||||
|
||||
# Never tag a commit whose manifest the default branch does not carry:
|
||||
# a branch protection rule (or a push that landed underneath this run)
|
||||
# rejecting this push makes the release impossible, so stop loudly and
|
||||
# say what to do instead of publishing an inconsistent artifact.
|
||||
if ! git push origin HEAD:master; then
|
||||
echo "::error::could not push the release commit for v${VERSION}: the" \
|
||||
"default branch rejected it (branch protection, or it moved during" \
|
||||
"this run). Nothing was tagged or published. Bump ${MANIFEST} to" \
|
||||
"${VERSION} manually, commit it, and re-run this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify the released manifest on master
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read the manifest from the REMOTE default branch, not from the
|
||||
# workspace: that is the copy HACS fetches, and it has to already carry
|
||||
# the version about to be tagged.
|
||||
git fetch --quiet origin master
|
||||
REMOTE_VERSION="$(git show "origin/master:${MANIFEST}" | jq -r '.version')"
|
||||
if [ "$REMOTE_VERSION" != "$VERSION" ]; then
|
||||
echo "::error::master's manifest says ${REMOTE_VERSION} but v${VERSION}" \
|
||||
"is being released — refusing to publish an inconsistent artifact." \
|
||||
"Re-run once master settles." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "master's manifest is ${REMOTE_VERSION}, matching v${VERSION}"
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
# HEAD is the commit the manifest was just verified on: the bumped one
|
||||
# when a write-back was needed, else the pushed commit itself. The tag
|
||||
# and the artifact therefore name the same version.
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
- name: Build the release zip
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd custom_components
|
||||
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
BUMP: ${{ steps.version.outputs.BUMP }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Notes: subject lines of the commits in the released range.
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${TAG}"
|
||||
# Notes: subject lines of the commits in the released range — the
|
||||
# commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
|
||||
# at the bot's own commit, so the range has to stop at its parent or every
|
||||
# release would open with "chore(release): vX [skip ci]". Without one the
|
||||
# tag is the pushed commit itself and belongs in the notes (t44 R1).
|
||||
if [ "$BUMP" = "yes" ]; then
|
||||
NOTES_END="${TAG}^"
|
||||
else
|
||||
RANGE="${TAG}"
|
||||
NOTES_END="${TAG}"
|
||||
fi
|
||||
PREV="$(git describe --tags --abbrev=0 "${NOTES_END}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${NOTES_END}"
|
||||
else
|
||||
RANGE="${NOTES_END}"
|
||||
fi
|
||||
{
|
||||
echo "Release ${TAG}"
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# Quality gate: lint + unit tests + the HA API-drift sweep.
|
||||
#
|
||||
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
|
||||
# vendored ha_adb_common helpers without installing Home Assistant — see
|
||||
# tests/conftest.py. androidtvremote2 is installed so the real-library
|
||||
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
|
||||
# firmware captures live in tests/fixtures
|
||||
# (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
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install test tooling
|
||||
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
|
||||
run: ruff check custom_components tests
|
||||
|
||||
- name: Unit tests
|
||||
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`
|
||||
integrations.
|
||||
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based
|
||||
> (`vYYYY.MM.DD`, plus `vYYYY.MM.DD.N` for a second release in the same day) —
|
||||
> see [Releases](#releases) for how a push becomes one.
|
||||
> Expect rough edges until the MVP phases land. The build-out plan is in
|
||||
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||||
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||||
@@ -52,14 +54,193 @@ Changes inherited from upstream still apply in 0.1.x code: polling is timer-base
|
||||
the ADB connection lives inside the media player entity, and fallback app state
|
||||
arrives in Phase 3.
|
||||
|
||||
## State ladder and fallback semantics (Phase 3)
|
||||
|
||||
The media player state is decided top-down; each tier serves only when the
|
||||
ones above it are silent. Both transports live in the entry's
|
||||
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
|
||||
remote protocol is the fast push/command path:
|
||||
|
||||
1. **Plug off** — the power switch says `off` → `off`.
|
||||
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
||||
3. **Intent** — a just-sent command's target state, self-healing against
|
||||
reality within 30 s (retry goes over the transport that served it).
|
||||
4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||
5. **ADB media session** — `playing` / `paused` / `buffering` for apps that
|
||||
publish a session (foreground app preferred). `buffering` is the media
|
||||
session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the
|
||||
stock-Android buffering value, **inferred** from that line shape: no captured
|
||||
fixture here contains `state=6` and no real device was available. A buffering
|
||||
session advertises the playback controls exactly like `playing`.
|
||||
6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the
|
||||
power plug on) and nothing is playing, or ADB reports it asleep (standby).
|
||||
A powered Shield with an app open and nothing playing reads `idle`.
|
||||
7. **On** — **no source reported a power or playback fact** (t58 UNIT 0; the
|
||||
earlier wording said "no data", which was too loose). `on` is the "we cannot
|
||||
know" state and the only situation it describes, so every source that
|
||||
*answers* — including an answer of "not playing" — stops the ladder at
|
||||
`idle` at worst, never here:
|
||||
- a configured **Jellyfin** entity in **any** state: `idle`, `off`,
|
||||
`standby` and `unavailable` all mean "its session is not playing", so they
|
||||
report — and because the ladder keeps going, a silent Jellyfin still
|
||||
cannot mask a live media session below it;
|
||||
- a **live media session with no playback flag** — the app publishes a
|
||||
session but is not playing, paused or buffering;
|
||||
- a **bare foreground app** — the protocol's `current_app` push or the ADB
|
||||
`mResumedActivity` parse answered;
|
||||
- a configured **power entity**, whatever it currently says;
|
||||
- protocol `is_on` / ADB awake, plugged or not.
|
||||
`on` survives only for an entry where none of those exists, and it never
|
||||
masks a `playing`/`paused`/`buffering` source.
|
||||
|
||||
The foreground tier is what keeps an app's identity visible while the state is
|
||||
`idle`: the `current_app` protocol push (then the ADB `mResumedActivity`
|
||||
parse), with its title from the TTL-capped last-known-state cache, the last
|
||||
channel entered, or the zero-cost "audio playback" hint
|
||||
(`media recently active`) — never resurrecting `playing` past the 60 s state
|
||||
TTL.
|
||||
|
||||
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
||||
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 5–6 plus
|
||||
the audio hint are what keep it visible.
|
||||
|
||||
**Standalone setup** offers both state sources at once — the Nvidia Remote
|
||||
Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
|
||||
Shield's address pre-filled when discovery found it. Either can be turned off,
|
||||
and each pairing step can be skipped (that transport stays off until you pair
|
||||
it), so ADB-only, protocol-only and fully passive entries are all one click
|
||||
apart. The `no_state_source` form error fires only when neither is enabled.
|
||||
|
||||
There is no other integration in the loop: one address, two transports, and the
|
||||
integration reads the device itself.
|
||||
|
||||
### Channel entry options
|
||||
|
||||
The options flow exposes the §7 channel behavior the integration replicates:
|
||||
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
|
||||
**ENTER press** after the digits (off by default — TiviMate's validated
|
||||
behavior). Both are consumed by `play_media` with
|
||||
`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)
|
||||
|
||||
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||
off**), the integration registers the `nvidia_shield.adb_command` service:
|
||||
send a raw ADB shell command to the entry's persistent connection
|
||||
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
|
||||
entity is optional when exactly one entry provides the service). This is the
|
||||
same security posture as the Home Assistant core `androidtv` integration: a
|
||||
documented, opt-in escape hatch for debugging and automation, registered at
|
||||
setup and unregistered when the last participating entity unloads. It
|
||||
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||||
|
||||
## Replacing androidtv / androidtv_remote + the glue
|
||||
|
||||
This fork replaces the old `androidtv` media player, the `androidtv_remote`
|
||||
integration and the surrounding YAML glue — there is nothing to keep alongside
|
||||
it: the entry's own two transports cover the whole device.
|
||||
|
||||
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
|
||||
2. **Remove the old platform YAML** — the `media_player:` blocks using
|
||||
`platform: androidtv` or `platform: androidtv_remote`, any `universal` media
|
||||
player template that fanned them in, and the `remote:`/`switch:` shims you
|
||||
added around them.
|
||||
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
|
||||
feeding them are replaced by this integration (table below).
|
||||
4. **Delete the old integrations** in Settings > Devices & Services, then
|
||||
**restart** Home Assistant.
|
||||
5. **Add this integration** — Settings > Devices & Services > Add Integration >
|
||||
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
|
||||
then walk the pairing steps.
|
||||
6. **Update your automations and dashboards** to the new entities:
|
||||
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
|
||||
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
|
||||
|
||||
What each retired piece used to provide, and what covers it now:
|
||||
|
||||
| Retired piece | Covered by |
|
||||
|---------------|------------|
|
||||
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) |
|
||||
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
|
||||
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
|
||||
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
|
||||
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
|
||||
|
||||
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
|
||||
comes from the ADB media session — the same tier every other app uses — so it
|
||||
needs no bridge either.
|
||||
|
||||
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
|
||||
full retirement mapping this section summarises.
|
||||
|
||||
## Releases
|
||||
|
||||
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
||||
- **Date-based versioning**: `vYYYY.MM.DD`, and `vYYYY.MM.DD.N` (N from 2) for a
|
||||
second release in the same UTC day.
|
||||
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
|
||||
run the release workflow (`.github/workflows/release.yml`):
|
||||
- A plain push publishes the current day's release (same-day pushes are deduped).
|
||||
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
|
||||
specific version's release before the date fallback kicks in.
|
||||
run the release workflow (`.gitea/workflows/release.yml`):
|
||||
- A plain push publishes today's release. If that date is already tagged it takes
|
||||
the lowest free `.N`, so a gap left by an earlier failure is reused rather than
|
||||
skipped.
|
||||
- Setting `version` in `custom_components/nvidia_shield/manifest.json` to an
|
||||
untagged value releases exactly that version instead.
|
||||
- **The release commit.** When the resolved version differs from the manifest, the
|
||||
workflow writes it back, commits `chore(release): v<VERSION> [skip ci]`, pushes it
|
||||
to `master`, and tags *that* commit. It has to do this: HACS compares the installed
|
||||
version against the manifest on the default branch, so a release that only ever
|
||||
existed as a tag is offered to nobody (v2026.10.09 shipped that way).
|
||||
- Because the tag names the bot commit, `git describe --tags` on a *developer*
|
||||
commit reports the **previous** release. That is expected, not a missing tag.
|
||||
- Bump it by hand instead and the tag lands on your own commit: set `version` in
|
||||
the manifest, commit, push. The workflow then has nothing to write back (it sees
|
||||
an untagged manifest version and tags your commit).
|
||||
- **`[skip ci]`**: the bot commit carries it, and the workflow's job additionally
|
||||
checks the commit *subject* (`chore(release): …`), so a write-back cannot start a
|
||||
second release. Both only recognise release commits — a normal commit whose message
|
||||
body merely mentions `[skip ci]` can still be skipped by Gitea's own handling, so
|
||||
keep the marker out of ordinary commit messages.
|
||||
- The workflow refuses to publish unless the default branch's manifest already carries
|
||||
the version it is about to tag, and fails loudly (with a "bump the manifest by hand"
|
||||
instruction and nothing tagged) if the write-back push is rejected, for example by
|
||||
branch protection.
|
||||
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
|
||||
installs. (No automatic mirroring — see the HACS note below.)
|
||||
|
||||
@@ -85,10 +266,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
|
||||
## Configuration
|
||||
|
||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||
2. Select the Android TV Remote entity for your Shield
|
||||
3. Enable ADB if you want now-playing from all apps
|
||||
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
||||
5. Optionally configure Jellyfin, receiver, power switch
|
||||
2. Give it a name and its address — the Shield's IP, pre-filled when discovery
|
||||
found the device
|
||||
3. Both transports are on by default: **Read state over ADB** (now-playing from
|
||||
every app) and **Add the Nvidia Remote Protocol** (power, availability, app
|
||||
push, volume readout). Uncheck what you do not want.
|
||||
4. If ADB is enabled, the next step pairs it — approve the prompt on the Shield
|
||||
5. The remote protocol pairs next — type the PIN shown on the Shield, or
|
||||
continue without pairing (that transport stays off until you pair it)
|
||||
6. Optionally configure Jellyfin, receiver, power switch
|
||||
|
||||
### ADB Pairing
|
||||
|
||||
@@ -98,20 +284,322 @@ device page in HA.
|
||||
|
||||
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
|
||||
|
||||
The Configure page is a **menu of sub-screens**, not one long form. Home Assistant
|
||||
renders it as a list of rows; each row opens a short form and comes back to the menu,
|
||||
and only **Save and close** commits:
|
||||
|
||||
| Row | What it holds |
|
||||
| --- | --- |
|
||||
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch |
|
||||
| **Receiver** | the receiver/soundbar entity and the input it uses |
|
||||
| **Apps** | the apps table — one row per app (see below) |
|
||||
| **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER |
|
||||
| **Save and close** | writes the merged options and reloads the integration |
|
||||
|
||||
Every field that used to be on the single page is still there, under one of those
|
||||
rows. Edits are merged **per key**, so visiting another sub-screen (or saving from a
|
||||
later one) never drops what an earlier one changed. Half-finished pages are not
|
||||
written: closing the dialog without **Save and close** leaves the entry as it was.
|
||||
|
||||
The **setup** flow follows the same shape: its first form is unchanged (name, host,
|
||||
transports, the linked entities), and after it the flow is a menu — **Receiver**,
|
||||
**Connection**, **Set up ADB**, **Pair the remote protocol**, **Create the entry** — so
|
||||
anything you want to correct is one row away instead of buried in a form you have
|
||||
already submitted. The two pairing rows appear only while that transport still needs
|
||||
pairing (a skip leaves its row there to retry; a successful pairing removes it), and
|
||||
the menu's description names what is still to pair.
|
||||
|
||||
### The Apps table
|
||||
|
||||
The **Apps** row is an editable table. One app per line, in the documented format:
|
||||
|
||||
```
|
||||
ar.tvplayer.tv=TiviMate
|
||||
com.plex.hdx=Plex|http://192.168.1.10:9000/plex.png
|
||||
com.netflix.ninja=Netflix
|
||||
```
|
||||
|
||||
`package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or
|
||||
a `camera.*` entity id — see *Sources* below). The field always opens carrying the
|
||||
current rows, so adding, renaming or deleting an app is an edit to that text:
|
||||
|
||||
- **add a row** — type a new line
|
||||
- **edit a row** — change its name, icon or package id
|
||||
- **remove a row** — delete its line
|
||||
|
||||
A blank field clears the table (and the integration falls back to its built-in app
|
||||
list). A malformed row — no `=`, or an empty name — is rejected with an error on the
|
||||
page rather than being half-saved.
|
||||
|
||||
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
|
||||
speaker that only knows on and off is a valid receiver. Playback then **ensures
|
||||
that switch is on**: `turn_on`, `play_media` and `select_source` call
|
||||
`switch.turn_on` when — and only when — it reports `off`, and touch nothing else
|
||||
about it (never volume, never a toggle). An already-on switch gets no call at all,
|
||||
a switch whose state cannot be read (`unavailable`/`unknown`) is left alone rather
|
||||
than guessed at, and a receiver that is off while the Shield is idle stays off.
|
||||
That is what makes receiver **volume scripts unnecessary** for this shape: there is
|
||||
no volume to script, only an on/off state to ensure. A `media_player.*` receiver
|
||||
keeps its own behaviour — input selection and attribute enrichment.
|
||||
|
||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||
- **Receiver + Input** — for media-zone coordination
|
||||
- **Receiver + Input** — for media-zone coordination; `media_player` or `switch`
|
||||
- **Power Switch** — smart plug entity
|
||||
- **ADB Host/Port** — override auto-detection if needed
|
||||
- **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
|
||||
transport on or off
|
||||
- **Shield address / ADB port** — the Shield's IP, and the ADB port if you
|
||||
changed it from 5555
|
||||
- **Command scripts** — one optional script per command, plus one for the remote
|
||||
entity; see below
|
||||
|
||||
### Command scripts (per-command overrides)
|
||||
|
||||
Some commands need hardware this integration deliberately does not drive: a speaker
|
||||
switch that owns the volume, an AVR input that has to move with the Shield, a mute
|
||||
that has to hit two devices. For those, the Configure page takes a **script per
|
||||
command**:
|
||||
|
||||
| Command | Runs when | Variables passed |
|
||||
| --- | --- | --- |
|
||||
| `turn_on`, `turn_off` | turn on/off, from the media player *or* the remote | — |
|
||||
| `volume_up`, `volume_down` | volume step | — |
|
||||
| `volume_set` | absolute volume (there is no native path — the protocol has VOLUME_UP/DOWN/MUTE only) | `level` |
|
||||
| `volume_mute` | mute/unmute | `is_volume_muted` |
|
||||
| `select_source` | source selection (the app list drives it natively; a script overrides) | `app_name` |
|
||||
|
||||
A command with a script calls the script **instead of** the protocol/ADB path, never
|
||||
both. Left unset — the default — every command behaves exactly as it did before, and
|
||||
clearing a selector on the Configure page restores that. Two of the seven appear as
|
||||
services only when there is something to do: `volume_set`, which has no native path at
|
||||
all, and `select_source`, which is offered once the integration has sources to offer
|
||||
(the app list below) — or a script.
|
||||
|
||||
The scripts are ordinary Home Assistant scripts. This block covers all three awkward
|
||||
cases: a speaker switch owning the volume, an AVR owning the input, and a mute that
|
||||
has to hit the switch as well.
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_volume: # select as "Volume set script"
|
||||
alias: Shield volume (speaker switch)
|
||||
sequence:
|
||||
- action: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.living_room_speaker
|
||||
data:
|
||||
volume_level: "{{ level }}"
|
||||
shield_source: # select as "Select source script"
|
||||
alias: Shield source (AVR)
|
||||
sequence:
|
||||
- action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.avr
|
||||
data:
|
||||
source: "{{ app_name }}"
|
||||
shield_mute: # select as "Mute script"
|
||||
alias: Shield mute (speaker switch)
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ is_volume_muted }}"
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
default:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
```
|
||||
|
||||
Then set `script.shield_volume` as **Volume set script**, `script.shield_source` as
|
||||
**Select source script** and so on on the Configure page. Scripts are set there, not
|
||||
in the YAML seed above — the seed only carries the address and the transport choice.
|
||||
|
||||
A script that fails surfaces as a translated error on the service call that triggered
|
||||
it (the command is not retried over protocol/ADB), because a silent no-op is the one
|
||||
outcome that leaves you guessing.
|
||||
|
||||
### App discovery (what is actually installed)
|
||||
|
||||
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
|
||||
start from a four-entry guess. Pick **Discover installed apps** on the Configure menu
|
||||
and the table is rebuilt from two ADB queries:
|
||||
|
||||
| Command | What it returns | How it is treated |
|
||||
| --- | --- | --- |
|
||||
| `pm list packages -3` | every **third-party** (user-installed) package, one `package:<id>` per line | all of them become rows |
|
||||
| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended |
|
||||
|
||||
System apps are **not** enumerated — a Shield ships a few hundred of them and they are
|
||||
not launchable apps — with the one exception that matters: the active launcher, which
|
||||
is a system app yet is exactly what the user sees. Nothing else is filtered, so an app
|
||||
is missing only if Android itself does not report it.
|
||||
|
||||
*(Every output shape above is the **documented** Android one, not an observed
|
||||
capture: no real Shield was available when this was written, so
|
||||
`pm list packages -3`, `resolve-activity --brief` and `pm dump` are exercised from
|
||||
fixtures — the same "inferred, not observed" mark the state=6 buffering value
|
||||
carries in the source ladder above.)*
|
||||
|
||||
Names come from the same cached path the Current App sensor uses: a `pm dump` per
|
||||
package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps
|
||||
in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in
|
||||
seconds instead of the minutes a strictly serialised sweep would take; the commands
|
||||
share the watchdog's own ADB connection, and `adb-shell` serialises the I/O on it, so
|
||||
the burst queues politely instead of interleaving on the socket. A label fetch
|
||||
that **fails is not cached**, so that app shows its package id in this table and the
|
||||
next sweep tries again — this is the same rule as the entity path, and it is why a
|
||||
transient ADB hiccup no longer sticks.
|
||||
|
||||
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
|
||||
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
|
||||
there is no device icon to read. Each app gets a curated `package → mdi:` icon where we
|
||||
know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin —
|
||||
and `mdi:television` otherwise. The icon never enters your **icon column**: that column
|
||||
is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an
|
||||
`mdi:` name there would put a value the media player cannot use into your own map.
|
||||
|
||||
Discovery does not run on the watchdog: the Configure row runs it, the dashboard
|
||||
sensor refreshes the table when it has no fresh one, and the result is cached for 6 h.
|
||||
With ADB off, or unreachable, the table simply shows the rows you wrote yourself and
|
||||
the menu says why. A sweep that **could not run** is not cached as an answer: it is
|
||||
recorded as `unknown` and retried, so a Shield that was asleep at startup is picked up
|
||||
by the next refresh instead of reporting an empty device for six hours.
|
||||
|
||||
**For dashboards** the same table is a sensor: `sensor.<name>_installed_apps`, whose
|
||||
state is the app **count** and whose `installed_apps` attribute is the list of
|
||||
`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `unknown`,
|
||||
`not_connected` or `adb_disabled`: `ok` means the table IS the answer — including a
|
||||
genuinely empty one — and `unknown` means the last sweep could not run (an empty list
|
||||
then says nothing about the device, and the next refresh retries). It only exists for
|
||||
ADB-enabled entries, and it is cheap to refresh — `homeassistant.update_entity` on it
|
||||
re-runs the sweep only when there is no fresh table (the cache expired, or the last
|
||||
sweep could not run), while the Configure row forces one.
|
||||
|
||||
```yaml
|
||||
# every installed app, with the name this integration would show
|
||||
{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }}
|
||||
| map(attribute='name') | list }}
|
||||
```
|
||||
|
||||
### Sources (the app list in a dropdown)
|
||||
|
||||
The media player's source list is your **Apps** map — the same map app-name resolution
|
||||
uses. With no map configured it falls back to a small built-in list
|
||||
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||
something to show. Discovery fills the **Configure** table, not this fallback: the
|
||||
dropdown stays exactly the map you saved, so what a card offers is always what you
|
||||
chose. An app that discovery found and you want in the dropdown is one row to add.
|
||||
|
||||
**Apps are sources, not browser media.** This integration no longer publishes the app
|
||||
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
|
||||
this entity (the device name, no rows) instead of a second, inconsistent copy of the
|
||||
same list — the user report was *"Media Browser shows Apps. To me it should probably be
|
||||
integrated with the home assistant media display, keep select sources for apps"*.
|
||||
Everything an app row could do lives in `source_list` and `media_player.select_source`
|
||||
below, and nothing else on this entity is browsable, so there is no dead entry to click
|
||||
and nothing else the browser could show.
|
||||
|
||||
```yaml
|
||||
# a media-player card, or anything that reads source_list
|
||||
type: media-control
|
||||
entity: media_player.living_room_shield
|
||||
```
|
||||
|
||||
- **`source_list`** — the app names. They come from the map (in the order it lists
|
||||
them), so editing the map renames the dropdown entries.
|
||||
- **`source`** — the app currently on screen, resolved exactly like the Current App
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
- **`media_player.select_source`** — picks one **by name** and launches it over the
|
||||
protocol, the same path `play_media` uses for apps. A raw package id works too (that
|
||||
holds the package id), names match case-insensitively, and a name that
|
||||
is not in the list is an error rather than a silent no-op:
|
||||
|
||||
```yaml
|
||||
action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.living_room_shield
|
||||
data:
|
||||
source: TiviMate
|
||||
```
|
||||
|
||||
Selecting a source is sometimes more than launching an app — an AVR input, a speaker
|
||||
switch. That is what the **Select source script** above is for: with one configured it
|
||||
replaces the native launch entirely.
|
||||
|
||||
#### Known limits (the Apps map)
|
||||
|
||||
The map is your own override, so two shapes are accepted rather than papered over:
|
||||
|
||||
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
|
||||
row — the first spelling — because name matching is case-insensitive. The shadowed
|
||||
app is not lost: `media_player.select_source` accepts its own package id.
|
||||
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||
dropdown, but `app_name`, `source` and the Current App sensor
|
||||
display the map value **verbatim** — so `source` can be a string that is not in
|
||||
`source_list`. A blank value falls through to the raw package instead.
|
||||
|
||||
### Remote command script
|
||||
|
||||
`remote.send_command` sends protocol keys: repeats, long presses (`START_LONG`/
|
||||
`END_LONG`) and inter-repeat delays. Some setups need that call to go somewhere else —
|
||||
an IR bridge, a macro, a second device — so one optional **Remote command script**
|
||||
replaces the whole key path when it is set. It receives the service's own parameters,
|
||||
named as they are on the service:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `command` | the list of key commands as submitted, e.g. `["HOME", "KEYCODE_DPAD_CENTER"]` |
|
||||
| `num_repeats` | how many times to send them (default `1`) |
|
||||
| `delay_secs` | the delay between repeats in seconds (default `0.0`) |
|
||||
| `hold_secs` | the requested long-press duration (default `0.0`) — the script owns the key semantics, so it decides what "hold" means |
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_remote: # select as "Remote command script"
|
||||
alias: Shield remote (IR bridge)
|
||||
sequence:
|
||||
- action: remote.send_command
|
||||
target:
|
||||
entity_id: remote.living_room_bridge
|
||||
data:
|
||||
command: "{{ command }}" # a list: pass it through
|
||||
num_repeats: "{{ num_repeats }}"
|
||||
delay_secs: "{{ delay_secs }}"
|
||||
hold_secs: "{{ hold_secs }}"
|
||||
```
|
||||
|
||||
All four variables are always present, including their defaults, so a script can rely
|
||||
on the shape. Unset — the default — nothing changes: keys go out over the protocol
|
||||
exactly as before.
|
||||
|
||||
## Entities
|
||||
|
||||
| Entity | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||
| Current App | `sensor` | Currently running app |
|
||||
| Now Playing | `sensor` | Formatted now-playing string |
|
||||
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
|
||||
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
|
||||
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -1,16 +1,156 @@
|
||||
"""The Nvidia Shield integration."""
|
||||
"""The Nvidia Shield integration.
|
||||
|
||||
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
|
||||
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
|
||||
socket and all ADB-derived state live there, not on the entities. Entities are
|
||||
thin callback-views over the manager's StateStore.
|
||||
|
||||
ADB is optional: a failed/absent ADB connection never blocks setup (no
|
||||
ConfigEntryNotReady) — the manager retries in the background, or stays
|
||||
passive until the user pairs via the Pair ADB button.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .adb_session import AdbMediaSessionPoller
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SERVICE_ADB_COMMAND,
|
||||
)
|
||||
from .helpers import (
|
||||
entry_host,
|
||||
protocol_cert_paths,
|
||||
resolve_adb_config,
|
||||
seed_adb_enabled,
|
||||
seed_host,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, 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):
|
||||
"""Poller factory when ADB is configured AND usable today, else None.
|
||||
|
||||
Returning None leaves the manager passive: the entry still sets up, the
|
||||
entities simply see no ADB state. The Pair ADB button / reconfigure
|
||||
triggers a reload that re-evaluates this.
|
||||
"""
|
||||
if not entry.options.get(CONF_ADB_ENABLED, False):
|
||||
return None
|
||||
|
||||
adb = resolve_adb_config(hass, entry)
|
||||
if adb is None:
|
||||
_LOGGER.warning(
|
||||
"ADB enabled but no host resolvable for entry %s — continuing "
|
||||
"without ADB state.",
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
# Only auto-connect when a key already exists — an ADB handshake against
|
||||
# an unpaired device is pointless noise (pairing happens via the Pair ADB
|
||||
# button / config flow, untouched).
|
||||
key_path_ready = await hass.async_add_executor_job(
|
||||
os.path.exists, adb.key_path
|
||||
)
|
||||
if not key_path_ready:
|
||||
_LOGGER.info(
|
||||
"ADB key not found for entry %s — skipping auto-connect. "
|
||||
"Use the Pair ADB button or reconfigure the integration.",
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
# 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:
|
||||
@@ -18,10 +158,114 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = entry
|
||||
|
||||
manager = ShieldConnectionManager(
|
||||
hass,
|
||||
entry,
|
||||
poller_factory=await _build_poller_factory(hass, entry),
|
||||
protocol_settings=await _build_protocol_settings(hass, entry),
|
||||
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
|
||||
)
|
||||
entry.runtime_data = manager
|
||||
await manager.async_start()
|
||||
|
||||
# Close the persistent socket on Home Assistant stop as well as unload.
|
||||
async def _close_on_hass_stop(_event) -> None:
|
||||
await manager.async_stop()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
return True
|
||||
|
||||
|
||||
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||
|
||||
The address is the entry's own single field (t41). Nothing is derived from
|
||||
another integration.
|
||||
"""
|
||||
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||
return None
|
||||
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
_LOGGER.info(
|
||||
"Protocol enabled for entry %s but no host resolvable — continuing "
|
||||
"without the remote-protocol transport. Pair via the flow.",
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
|
||||
return {
|
||||
"host": host,
|
||||
"client_name": entry.title or "Home Assistant",
|
||||
"certfile": certfile,
|
||||
"keyfile": keyfile,
|
||||
}
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate an older config entry to the current version.
|
||||
|
||||
v1 → v2 added the remote-protocol fields (ADB options carried over
|
||||
untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
|
||||
|
||||
* the duplicated address fields collapse into the single CONF_HOST — the
|
||||
protocol host wins, else the ADB host, since both name the same Shield;
|
||||
* the androidtv-entity key goes. This integration owns the whole stack now:
|
||||
the entity it used to configure is neither a state source nor an app-name
|
||||
or power tier, so keeping the value would only mislead.
|
||||
|
||||
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
|
||||
"""
|
||||
if config_entry.version > ENTRY_VERSION:
|
||||
return False
|
||||
if config_entry.version >= ENTRY_VERSION:
|
||||
return True
|
||||
|
||||
options = {**config_entry.options}
|
||||
data = {**config_entry.data}
|
||||
|
||||
if config_entry.version < 2:
|
||||
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
||||
|
||||
if not options.get(CONF_HOST):
|
||||
options[CONF_HOST] = (
|
||||
options.get(CONF_PROTOCOL_HOST)
|
||||
or options.get(CONF_ADB_HOST)
|
||||
or data.get(CONF_PROTOCOL_HOST)
|
||||
or data.get(CONF_ADB_HOST)
|
||||
or ""
|
||||
)
|
||||
|
||||
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
|
||||
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
|
||||
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
options.pop(legacy_key, None)
|
||||
data.pop(legacy_key, None)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=data, options=options, version=ENTRY_VERSION
|
||||
)
|
||||
if dropped_entity:
|
||||
_LOGGER.info(
|
||||
"%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
|
||||
|
||||
|
||||
@@ -30,11 +274,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
if manager is not None:
|
||||
await manager.async_stop()
|
||||
entry.runtime_data = None
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
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)
|
||||
@@ -1,15 +1,24 @@
|
||||
"""ADB media session reader for NVIDIA Shield.
|
||||
"""ADB session adapter for NVIDIA Shield.
|
||||
|
||||
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
|
||||
`poll()` method that calls wake check + media session in sequence.
|
||||
Since Phase 1 this module is a thin adapter on top of ha_adb_common:
|
||||
|
||||
- ``ensure_adb_key`` (re-exported here for the config flow / Pair ADB button)
|
||||
does the one-time key-material setup — the pairing flow stays untouched.
|
||||
- ``AdbMediaSessionPoller`` remains the protocol-level ADB client used by the
|
||||
ShieldConnectionManager (connection.py) and one-shot pairing connects. The
|
||||
polling loop itself lives in the connection manager, not here.
|
||||
|
||||
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
|
||||
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
|
||||
from .ha_adb_common import (
|
||||
AdbClientBase,
|
||||
MediaSessionInfo, # noqa: F401 (re-export)
|
||||
parse_foreground,
|
||||
)
|
||||
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
|
||||
prefer its media session over stale sessions from other apps.
|
||||
"""
|
||||
return await self.poll_media_sessions(foreground_package)
|
||||
|
||||
async def poll_foreground(self) -> str | None:
|
||||
"""Foreground package via `dumpsys activity activities` (RESEARCH R4).
|
||||
|
||||
The grep keeps the dump tiny; the parser handles the R4 line wrap.
|
||||
"""
|
||||
dump = await self.shell("dumpsys activity activities 2>/dev/null | grep -iE 'resumed'")
|
||||
if not dump:
|
||||
return None
|
||||
return parse_foreground(dump)
|
||||
@@ -5,19 +5,17 @@ import logging
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import entry_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +34,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Pair ADB"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:link-variant"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
@@ -52,29 +51,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
def _resolve_host(self) -> str | None:
|
||||
"""Resolve ADB host from config or Android TV entity."""
|
||||
host = self._entry.options.get(CONF_ADB_HOST)
|
||||
if host:
|
||||
return host
|
||||
|
||||
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
if not atv_entity:
|
||||
return None
|
||||
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(atv_entity)
|
||||
if not entity_entry or not entity_entry.config_entry_id:
|
||||
return None
|
||||
|
||||
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||
if config_entry:
|
||||
return config_entry.data.get("host")
|
||||
return None
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||
host = self._resolve_host()
|
||||
# t41: the entry's own single address — never derived from another
|
||||
# integration's entity.
|
||||
host = entry_host(self._entry)
|
||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||
|
||||
if not host:
|
||||
@@ -85,7 +66,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
ensure_adb_key, self.hass.config.config_dir
|
||||
)
|
||||
|
||||
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
|
||||
_LOGGER.info(
|
||||
"Attempting ADB pairing with %s:%d — approve the prompt on the Shield",
|
||||
host,
|
||||
port,
|
||||
)
|
||||
|
||||
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||
if await poller.connect():
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
|
||||
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
|
||||
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
|
||||
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
|
||||
# runtime code reads them.
|
||||
CONF_HOST = "host"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_PROTOCOL_HOST = "protocol_host"
|
||||
|
||||
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
|
||||
# path it configured is gone), never read at runtime.
|
||||
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
|
||||
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
||||
CONF_RECEIVER_ENTITY = "receiver_entity"
|
||||
@@ -19,20 +29,249 @@ CONF_RECEIVER_INPUT = "receiver_input"
|
||||
CONF_POWER_ENTITY = "power_entity"
|
||||
CONF_ADB_ENABLED = "adb_enabled"
|
||||
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_ADB_PORT = "adb_port"
|
||||
|
||||
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||
CONF_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_PAIR_PORT = 6467
|
||||
|
||||
ADB_POLL_INTERVAL = 10 # seconds
|
||||
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
||||
DEFAULT_ADB_PORT = 5555
|
||||
|
||||
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
|
||||
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
|
||||
# fields expire (B5) instead of clinging to stale values.
|
||||
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
|
||||
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
||||
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
||||
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||
|
||||
DEFAULT_NAME = "Nvidia Shield"
|
||||
|
||||
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
|
||||
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
|
||||
# androidtv-entity key. One constant so the flow and the migration cannot drift.
|
||||
ENTRY_VERSION = 3
|
||||
|
||||
# Emotiva DAG attribute we read to get available inputs
|
||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||
|
||||
# Known Jellyfin app package IDs
|
||||
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
|
||||
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
|
||||
JELLYFIN_APP_IDS = [
|
||||
"org.jellyfin.androidtv",
|
||||
"org.jellyfin.mobile",
|
||||
"com.github.damontecres.wholphin",
|
||||
]
|
||||
|
||||
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
|
||||
# HA service kind → protocol keycode string.
|
||||
PROTOCOL_MEDIA_KEYS = {
|
||||
"media_play": "KEYCODE_MEDIA_PLAY",
|
||||
"media_pause": "KEYCODE_MEDIA_PAUSE",
|
||||
"media_stop": "KEYCODE_MEDIA_STOP",
|
||||
"media_next_track": "KEYCODE_MEDIA_NEXT",
|
||||
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
|
||||
}
|
||||
PROTOCOL_VOLUME_KEYS = {
|
||||
"volume_up": "KEYCODE_VOLUME_UP",
|
||||
"volume_down": "KEYCODE_VOLUME_DOWN",
|
||||
"volume_mute": "KEYCODE_VOLUME_MUTE",
|
||||
}
|
||||
PROTOCOL_REMOTE_KEYS = {
|
||||
**PROTOCOL_VOLUME_KEYS,
|
||||
"play": "KEYCODE_MEDIA_PLAY",
|
||||
"pause": "KEYCODE_MEDIA_PAUSE",
|
||||
"stop": "KEYCODE_MEDIA_STOP",
|
||||
"up": "KEYCODE_DPAD_UP",
|
||||
"down": "KEYCODE_DPAD_DOWN",
|
||||
"left": "KEYCODE_DPAD_LEFT",
|
||||
"right": "KEYCODE_DPAD_RIGHT",
|
||||
"enter": "KEYCODE_DPAD_CENTER",
|
||||
"home": "KEYCODE_HOME",
|
||||
"back": "KEYCODE_BACK",
|
||||
"menu": "KEYCODE_MENU",
|
||||
"search": "KEYCODE_SEARCH",
|
||||
"power": "KEYCODE_POWER",
|
||||
"channel_up": "KEYCODE_CHANNEL_UP",
|
||||
"channel_down": "KEYCODE_CHANNEL_DOWN",
|
||||
}
|
||||
|
||||
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
|
||||
# (replicates HA core androidtv_remote's verified behavior).
|
||||
# Zeroconf discovery (Phase 4): the Android TV remote service type.
|
||||
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
|
||||
|
||||
# P4-1 apps name/icon map (options flow editor): package → friendly name,
|
||||
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
||||
CONF_APP_NAME_MAP = "app_name_map"
|
||||
|
||||
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
|
||||
# entity_picture when no Jellyfin session picture is live.
|
||||
CONF_APP_ICON_MAP = "app_icon_map"
|
||||
|
||||
# P5-1: device manifest label cache TTL (long — labels change with app
|
||||
# updates, not per poll).
|
||||
APP_LABEL_TTL = 24 * 3600
|
||||
|
||||
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
|
||||
# told from a package that genuinely has no label. ``adb shell`` output ending
|
||||
# in this marker means the command ran; an empty answer still carries it, and
|
||||
# a transport that never delivered output does not. The value is arbitrary but
|
||||
# unlikely to collide with a manifest line.
|
||||
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
|
||||
|
||||
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||
# (same security posture as the core androidtv integration).
|
||||
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||
SERVICE_ADB_COMMAND = "adb_command"
|
||||
|
||||
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
||||
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
||||
# reports stability on their device.
|
||||
CONF_LOGCAT_EVENTS = "logcat_events"
|
||||
|
||||
# P5-3: duplicate START for the same package inside this window is not
|
||||
# re-stamped (repeat suppression).
|
||||
LOGCAT_SUPPRESS_WINDOW = 15.0
|
||||
|
||||
# P4-2c BrowseMedia app directory fallback when the options map is empty
|
||||
# (curated; the packages are the plan's reference apps).
|
||||
DEFAULT_APP_DIRECTORY = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.github.damontecres.wholphin": "Wholphin",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.google.android.youtube.tv": "YouTube",
|
||||
}
|
||||
|
||||
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
|
||||
# app's icon resource (an extracted APK's icon is a bitmap only its own code
|
||||
# can resolve, and `dumpsys package` prints no drawable reference), so a device
|
||||
# icon is not obtainable over plain shell. These MDI names are therefore a
|
||||
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
|
||||
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
|
||||
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
|
||||
APP_ICON_FALLBACKS = {
|
||||
"ar.tvplayer.tv": "mdi:television-classic",
|
||||
"com.github.damontecres.wholphin": "mdi:jellyfish",
|
||||
"com.netflix.ninja": "mdi:netflix",
|
||||
"com.google.android.youtube.tv": "mdi:youtube-tv",
|
||||
"com.google.android.tvlauncher": "mdi:view-dashboard",
|
||||
"org.xbmc.kodi": "mdi:kodi",
|
||||
"com.plexapp.android": "mdi:plex",
|
||||
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
|
||||
"com.disney.disneyplus": "mdi:movie-open",
|
||||
"com.spotify.tv.android": "mdi:spotify",
|
||||
"com.google.android.apps.youtube.music": "mdi:music",
|
||||
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
|
||||
"com.spotify.music": "mdi:spotify",
|
||||
}
|
||||
#: The fallback for an app this map has never heard of.
|
||||
APP_ICON_GENERIC = "mdi:television"
|
||||
|
||||
# t57: installed-app discovery. The package list changes only when the user
|
||||
# installs or uninstalls something, so a long TTL is right; refreshing is
|
||||
# explicit (the options page and the app-list sensor ask for it) rather than a
|
||||
# per-poll cost.
|
||||
APP_LIST_TTL = 6 * 3600
|
||||
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
|
||||
#: typical Shield quickly, few enough that the label fetches never queue
|
||||
#: behind each other on one socket or stall the watchdog.
|
||||
APP_SWEEP_CONCURRENCY = 4
|
||||
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
|
||||
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
|
||||
#: a few seconds instead of a few minutes.
|
||||
APP_SWEEP_SPACING = 0.05
|
||||
|
||||
CHANNEL_DIGIT_DELAY = 0.1
|
||||
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
||||
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
||||
|
||||
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
|
||||
# not publish a MediaSession on Shield Android 11 — it is the headline
|
||||
# Phase-3 fallback app.
|
||||
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
|
||||
|
||||
# Packages to ignore when parsing foreground/audio-playback identities
|
||||
SYSTEM_MEDIA_PACKAGES = [
|
||||
"com.google.android.apps.mediashell", # Chromecast receiver process
|
||||
"com.android.systemui",
|
||||
"com.android.tv.settings",
|
||||
"com.nvidia.diagtools",
|
||||
]
|
||||
|
||||
|
||||
# t44: per-command script overrides — the escape hatch for hardware this
|
||||
# integration deliberately does not drive itself (a speaker switch that owns the
|
||||
# volume, an AVR input, a mute that has to hit two devices). One script per
|
||||
# command: when one is configured, that command calls the script INSTEAD of the
|
||||
# native protocol/ADB path, and with none configured nothing changes at all.
|
||||
CONF_COMMAND_SCRIPTS = "command_scripts"
|
||||
SCRIPT_COMMANDS = (
|
||||
"turn_on",
|
||||
"turn_off",
|
||||
"volume_up",
|
||||
"volume_down",
|
||||
"volume_set",
|
||||
"volume_mute",
|
||||
"select_source",
|
||||
)
|
||||
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
|
||||
SCRIPT_FIELD_PREFIX = "script_"
|
||||
#: Variables a command hands its script (HA script variables). volume_mute passes
|
||||
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
|
||||
SCRIPT_VAR_LEVEL = "level"
|
||||
SCRIPT_VAR_APP_NAME = "app_name"
|
||||
SCRIPT_VAR_MUTED = "is_volume_muted"
|
||||
|
||||
|
||||
def command_scripts(options) -> dict[str, str]:
|
||||
"""The script configured per command, normalized (t44).
|
||||
|
||||
The ONE place the stored option's shape is read: the options flow, the media
|
||||
player and the remote entity all go through it. Options round-trip through HA
|
||||
storage and can be hand-edited, so a value that is not a non-empty string for a
|
||||
known command is dropped rather than handed to the service layer.
|
||||
"""
|
||||
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
scripts: dict[str, str] = {}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
script = raw.get(command)
|
||||
if isinstance(script, str) and script.strip():
|
||||
scripts[command] = script.strip()
|
||||
return scripts
|
||||
|
||||
|
||||
# t47: ONE optional script for the remote entity's send_command. It replaces the
|
||||
# whole key path (repeats, hold, delays included) rather than per-key overrides,
|
||||
# because a remote command is a sequence: an IR bridge, a macro or a second device
|
||||
# needs the call, not individual keys.
|
||||
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
|
||||
#: Variables remote.send_command hands its script — the names are the service's own
|
||||
#: parameter names, and they are documented in the README and pinned in tests.
|
||||
SCRIPT_VAR_COMMAND = "command"
|
||||
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
|
||||
SCRIPT_VAR_DELAY_SECS = "delay_secs"
|
||||
SCRIPT_VAR_HOLD_SECS = "hold_secs"
|
||||
|
||||
|
||||
def remote_command_script(options) -> str | None:
|
||||
"""The script configured for remote.send_command, or None (t47).
|
||||
|
||||
Same defensive read as ``command_scripts``: options round-trip through HA
|
||||
storage and can be hand-edited, so anything that is not a non-empty string is
|
||||
"not configured" rather than a service call to nowhere.
|
||||
"""
|
||||
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||
if isinstance(script, str) and script.strip():
|
||||
return script.strip()
|
||||
return None
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Entry diagnostics for Nvidia Shield (P4-2b, t21 F1 platform-module move).
|
||||
|
||||
HA discovers ``async_get_config_entry_diagnostics`` through the integration's
|
||||
``diagnostics`` platform module — a placement inside media_player.py never
|
||||
serves in a real install (t20 finding F1).
|
||||
|
||||
The payload: redacted entry config (data + options), the manager's StateStore
|
||||
snapshot via the public ``store_snapshot`` accessor, and both transports'
|
||||
health. Cert paths are kept readable; key material / pairing secrets never
|
||||
exist in stored state, and any key carrying a secret-shaped name is redacted
|
||||
defensively.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import asdict, is_dataclass
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.redact import async_redact_data
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .media_player import entity_available
|
||||
|
||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||
_REDACTED = "**REDACTED**"
|
||||
|
||||
# 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):
|
||||
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
||||
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
key: (
|
||||
_REDACTED
|
||||
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
|
||||
else _redact(value)
|
||||
)
|
||||
for key, value in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _store_snapshot(manager) -> dict[str, dict]:
|
||||
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
|
||||
snapshot = {}
|
||||
for name, (stamp, value) in manager.store.values_stamped().items():
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
value = asdict(value)
|
||||
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||
return snapshot
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict:
|
||||
"""Entry diagnostics: redacted config + store snapshot + transport health."""
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
payload: dict = {
|
||||
"entry": {
|
||||
"data": dict(getattr(entry, "data", {}) or {}),
|
||||
"options": dict(getattr(entry, "options", {}) or {}),
|
||||
},
|
||||
"store": {},
|
||||
"transport": {},
|
||||
}
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
payload["store"] = manager.store_snapshot()
|
||||
payload["transport"] = {
|
||||
# t40: the ladder's verdict, from the same helper the entities use,
|
||||
# so a support dump answers "why is my entity unavailable?".
|
||||
"entity_available": entity_available(manager),
|
||||
"adb": {
|
||||
"configured": manager.available,
|
||||
"connected": manager.connected,
|
||||
"awake": manager.awake,
|
||||
},
|
||||
"protocol": {
|
||||
"active": manager.protocol_active,
|
||||
"available": manager.is_available,
|
||||
"is_on": manager.is_on,
|
||||
},
|
||||
}
|
||||
# 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"]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||
|
||||
from .adb_client import AdbClientBase
|
||||
from .adb_client import AdbClientBase, parse_wakefulness
|
||||
from .app_label import parse_application_label
|
||||
from .const import (
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
@@ -9,9 +10,23 @@ from .const import (
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||
)
|
||||
from .foreground import parse_foreground
|
||||
from .installed_apps import (
|
||||
icon_for_package,
|
||||
installed_packages,
|
||||
is_launchable,
|
||||
parse_active_launcher,
|
||||
parse_package_list,
|
||||
)
|
||||
from .intent import PlaybackIntent
|
||||
from .keys import ensure_adb_key
|
||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||
from .logcat_events import foreground_from_logcat
|
||||
from .media_session import (
|
||||
MediaSessionInfo,
|
||||
parse_all_media_sessions,
|
||||
parse_audio_playback,
|
||||
parse_media_session,
|
||||
)
|
||||
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||
|
||||
__all__ = [
|
||||
@@ -26,6 +41,16 @@ __all__ = [
|
||||
"PlaybackIntent",
|
||||
"ensure_adb_key",
|
||||
"parse_all_media_sessions",
|
||||
"parse_application_label",
|
||||
"parse_audio_playback",
|
||||
"parse_foreground",
|
||||
"foreground_from_logcat",
|
||||
"icon_for_package",
|
||||
"installed_packages",
|
||||
"is_launchable",
|
||||
"parse_active_launcher",
|
||||
"parse_package_list",
|
||||
"parse_wakefulness",
|
||||
"parse_media_session",
|
||||
"running_apps_from_sessions",
|
||||
"running_apps_from_tasks",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||
|
||||
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
|
||||
|
||||
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
||||
|
||||
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
|
||||
# Anything else is "unknown" — never silently reported as awake or asleep.
|
||||
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
|
||||
|
||||
|
||||
def parse_wakefulness(text: str | None) -> bool | None:
|
||||
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
|
||||
|
||||
Exact-value parse, no substring matching: only the ``mWakefulness=``
|
||||
assignment counts — ``mWakefulnessChanging=false`` and
|
||||
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
|
||||
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
|
||||
as "unknown", never as awake or asleep.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
|
||||
if match:
|
||||
value = match.group(1)
|
||||
if value == "Awake":
|
||||
return True
|
||||
if value in _WAKEFULNESS_ASLEEP_VALUES:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class AdbClientBase:
|
||||
"""Base ADB client with connection management and common commands."""
|
||||
@@ -43,12 +72,28 @@ class AdbClientBase:
|
||||
|
||||
# Media session state
|
||||
self._last_info: MediaSessionInfo | None = None
|
||||
self._last_dump: str | None = None
|
||||
self._all_sessions: list[MediaSessionInfo] = []
|
||||
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
"""Public connection config: {host, port, key_path} (t27 F-NEW3).
|
||||
|
||||
Replaces the private-attr reach-ins for secondary streams — callers
|
||||
(the dedicated logcat stream) share the signer material without
|
||||
touching the poller's socket or its private attributes.
|
||||
"""
|
||||
return {
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"key_path": self._key_path,
|
||||
}
|
||||
|
||||
@property
|
||||
def last_info(self) -> MediaSessionInfo | None:
|
||||
return self._last_info
|
||||
@@ -60,6 +105,7 @@ class AdbClientBase:
|
||||
def clear_info(self) -> None:
|
||||
"""Clear the cached media session info."""
|
||||
self._last_info = None
|
||||
self._last_dump = None
|
||||
self._all_sessions = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
@@ -90,18 +136,15 @@ class AdbClientBase:
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
if self._device:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._device.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._device = None
|
||||
self._connected = False
|
||||
|
||||
async def shell(self, cmd: str) -> str | None:
|
||||
"""Run a shell command, reconnecting if needed."""
|
||||
if not self._connected or not self._device:
|
||||
if not await self.connect():
|
||||
return None
|
||||
if (not self._connected or not self._device) and not await self.connect():
|
||||
return None
|
||||
try:
|
||||
return await self._device.shell(cmd)
|
||||
except Exception as err:
|
||||
@@ -135,6 +178,24 @@ class AdbClientBase:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||
"""Send a channel digit via `input keyevent` (KEYCODE_0..9 = 7..16)."""
|
||||
if not digit.isdigit() or len(digit) != 1:
|
||||
_LOGGER.warning("Not a single digit: %s", digit)
|
||||
return False
|
||||
result = await self.shell(f"input keyevent {7 + int(digit)}")
|
||||
if result is not None:
|
||||
_LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit)
|
||||
return result is not None
|
||||
|
||||
async def send_enter_keyevent(self) -> bool:
|
||||
"""Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER
|
||||
fallback for the §7 channel sequence."""
|
||||
result = await self.shell("input keyevent 66")
|
||||
if result is not None:
|
||||
_LOGGER.debug("ADB ENTER keyevent (66)")
|
||||
return result is not None
|
||||
|
||||
async def bring_to_foreground(self, package: str) -> bool:
|
||||
"""Bring an app to the foreground using monkey command."""
|
||||
result = await self.shell(
|
||||
@@ -145,27 +206,60 @@ class AdbClientBase:
|
||||
return result is not None
|
||||
|
||||
async def is_awake(self) -> bool | None:
|
||||
"""Check if the device screen is on / awake via ADB."""
|
||||
"""Check if the device screen is on / awake via ADB.
|
||||
|
||||
Exact-value mWakefulness= parse (R5); None means "unknown".
|
||||
"""
|
||||
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||
if result:
|
||||
return "Awake" in result
|
||||
return None
|
||||
return parse_wakefulness(result)
|
||||
|
||||
# -- Media session polling --
|
||||
|
||||
async def poll_media_sessions(
|
||||
self, foreground_package: str | None = None, head_lines: int = 200
|
||||
self, foreground_package: str | None = None, head_lines: int | None = 200
|
||||
) -> MediaSessionInfo | None:
|
||||
"""Query media sessions. Stores all sessions and returns the best match."""
|
||||
dump = await self.shell(
|
||||
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
|
||||
)
|
||||
"""Query media sessions. Stores all sessions and returns the best match.
|
||||
|
||||
Args:
|
||||
foreground_package: Prefer the session of the foreground app.
|
||||
head_lines: Truncate the dump to N lines (the historical default
|
||||
of 200). Pass None for the full dump — the ShieldConnection
|
||||
manager does so, since the cut silently drops sessions below
|
||||
it (plan blocker B4).
|
||||
"""
|
||||
command = "dumpsys media_session 2>/dev/null"
|
||||
if head_lines is not None:
|
||||
command += f" | head -{head_lines}"
|
||||
dump = await self.shell(command)
|
||||
self._last_dump = dump if dump is not None else None
|
||||
if dump is None:
|
||||
return None
|
||||
self._all_sessions = parse_all_media_sessions(dump)
|
||||
self._last_info = parse_media_session(dump, foreground_package)
|
||||
return self._last_info
|
||||
|
||||
@property
|
||||
def last_dump(self) -> str | None:
|
||||
"""Raw output of the last media-session query (or None if it failed).
|
||||
|
||||
Shared consumers (the ShieldConnectionManager) parse the "Audio
|
||||
playback" section from this same dump — no extra round trip.
|
||||
"""
|
||||
return self._last_dump
|
||||
|
||||
# -- Health check --
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Verify the connection is actually usable with a cheap shell round trip.
|
||||
|
||||
Returns True only when the device answers the trivial ``echo ok`` with
|
||||
the expected body — a stronger signal than the ``connected`` flag,
|
||||
which can lag behind reality (the watchdog uses this to fire a
|
||||
reconnect instead of trusting a dead socket).
|
||||
"""
|
||||
result = await self.shell("echo ok")
|
||||
return result is not None and result.strip() == "ok"
|
||||
|
||||
# -- Running apps (recent visible tasks) --
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
@@ -182,12 +276,11 @@ class AdbClientBase:
|
||||
if not result:
|
||||
return []
|
||||
|
||||
import re
|
||||
apps = []
|
||||
seen = set()
|
||||
|
||||
# System packages to exclude
|
||||
_EXCLUDED = {
|
||||
excluded = {
|
||||
"com.android.systemui",
|
||||
"com.android.tv.settings",
|
||||
"com.nvidia.diagtools",
|
||||
@@ -206,7 +299,7 @@ class AdbClientBase:
|
||||
match = re.search(r"I=(\S+)/", stripped)
|
||||
if match:
|
||||
pkg = match.group(1)
|
||||
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
|
||||
if pkg not in seen and pkg not in excluded and not pkg.startswith("."):
|
||||
seen.add(pkg)
|
||||
apps.append({"package": pkg})
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""App friendly-name resolution from the package manifest (P5-1).
|
||||
|
||||
Parses `pm dump <package>` output for the ``application-label`` line — the
|
||||
default-locale variant; localized suffixed variants
|
||||
(``application-label-<lang>:``) are skipped, and multi-label blocks across
|
||||
locales are tolerated (first default-locale label wins), mirroring the
|
||||
tolerant pattern of the androidtv-adb core helper.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_APPLICATION_LABEL_RE = re.compile(
|
||||
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||
)
|
||||
|
||||
|
||||
def parse_application_label(dump: str) -> str | None:
|
||||
"""The package's default-locale application label, or None.
|
||||
|
||||
Returns None on empty/failed dumps too — callers fall through to the
|
||||
next name tier.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _APPLICATION_LABEL_RE.finditer(dump):
|
||||
label = (match.group(1) or match.group(2) or "").strip()
|
||||
if label and label not in ("null", ""):
|
||||
return label
|
||||
return None
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
|
||||
|
||||
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
|
||||
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
|
||||
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
|
||||
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
|
||||
|
||||
Multi-line ActivityRecord wraps are handled by joining the block until the
|
||||
record closes or a state line arrives (R4 finding 3: match on the joined
|
||||
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_RESUMED_MARKERS = (
|
||||
("mResumedActivity", "mResumedActivity:"),
|
||||
("topResumedActivity", "topResumedActivity:"),
|
||||
("ResumedActivity", "ResumedActivity:"),
|
||||
("Resumed", "Resumed:"),
|
||||
)
|
||||
|
||||
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
|
||||
# anything up to the first `/` (or closing brace when the activity is bare).
|
||||
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
|
||||
|
||||
|
||||
def _marker_kind(stripped: str) -> str | None:
|
||||
for kind, marker in _RESUMED_MARKERS:
|
||||
if stripped.startswith(marker):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
|
||||
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
|
||||
blocks: list[tuple[str, str]] = []
|
||||
kind: str | None = None
|
||||
buf = ""
|
||||
for raw in dump.split("\n"):
|
||||
stripped = raw.strip()
|
||||
marker = _marker_kind(stripped)
|
||||
if marker is not None:
|
||||
if buf:
|
||||
blocks.append((kind, buf))
|
||||
kind, buf = marker, stripped
|
||||
continue
|
||||
if kind is None:
|
||||
continue
|
||||
# Continuation of a wrapped ActivityRecord: join until the record
|
||||
# closes (brace) or the state line completes the block (R4 finding 3).
|
||||
buf += " " + stripped
|
||||
if "}" in stripped or "state=" in stripped:
|
||||
blocks.append((kind, buf))
|
||||
kind, buf = None, ""
|
||||
if buf:
|
||||
blocks.append((kind, buf))
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_package(text: str) -> str | None:
|
||||
match = _PACKAGE_RE.search(text)
|
||||
if match is None:
|
||||
return None
|
||||
package = match.group(1) or match.group(2)
|
||||
if not package or package in ("null", "Window{"):
|
||||
return None
|
||||
return package
|
||||
|
||||
|
||||
def parse_foreground(dump: str) -> str | None:
|
||||
"""Foreground package: mResumedActivity primary, topResumed fallback.
|
||||
|
||||
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
|
||||
callers must not fall back to window-focus parsing (R4 finding 2).
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
blocks = _resumed_blocks(dump)
|
||||
for kind, text in blocks:
|
||||
if kind == "topResumedActivity":
|
||||
continue
|
||||
package = _extract_package(text)
|
||||
if package:
|
||||
return package
|
||||
for kind, text in blocks:
|
||||
if kind != "topResumedActivity":
|
||||
continue
|
||||
package = _extract_package(text)
|
||||
if package:
|
||||
return package
|
||||
return None
|
||||
@@ -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(".")
|
||||
)
|
||||
@@ -13,20 +13,34 @@ class PlaybackIntent:
|
||||
|
||||
Attributes:
|
||||
target_state: The MediaPlayerState we expect after the command.
|
||||
command: The service/command name (for retries and logging).
|
||||
command: The command/service name (for retries and logging).
|
||||
created_at: Monotonic timestamp when the intent was created.
|
||||
prior_state: The source state before the intent (for detecting external overrides).
|
||||
retry_count: Number of retries attempted so far.
|
||||
via_adb: True when the command was successfully served by the ADB
|
||||
keyevent path — the retry must then go over ADB too, not back to
|
||||
the remote that did not serve it.
|
||||
adb_command: The ADB keycode command name matching the intent
|
||||
(play/pause/stop/next/previous); set together with via_adb.
|
||||
via_protocol: True when the command was served by the remote-protocol
|
||||
key push (Phase 2) — the retry then goes over the protocol key
|
||||
again instead of the other transports.
|
||||
protocol_key: The protocol keycode string served with via_protocol.
|
||||
"""
|
||||
|
||||
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
||||
command: str
|
||||
created_at: float
|
||||
prior_state: str | None = None
|
||||
retry_count: int = 0
|
||||
via_adb: bool = False
|
||||
adb_command: str | None = None
|
||||
via_protocol: bool = False
|
||||
protocol_key: str | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
|
||||
def create(
|
||||
cls, target_state: str, command: str, prior_state: str | None = None
|
||||
) -> "PlaybackIntent":
|
||||
return cls(
|
||||
target_state=target_state,
|
||||
command=command,
|
||||
@@ -45,4 +59,6 @@ class PlaybackIntent:
|
||||
"command": self.command,
|
||||
"retries": self.retry_count,
|
||||
"age_s": self.age_seconds,
|
||||
"via_adb": self.via_adb,
|
||||
"adb_command": self.adb_command,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
|
||||
|
||||
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
|
||||
the ``cmp=package`` part so the connection manager can stamp the foreground
|
||||
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
|
||||
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
|
||||
source) is untouched: the stream never reads playback state.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
|
||||
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
|
||||
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
|
||||
|
||||
|
||||
def start_records(lines: list[str]) -> list[str]:
|
||||
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
|
||||
|
||||
A record opens at a line that matches a log timestamp and mentions
|
||||
ActivityTaskManager + START; subsequent lines that do NOT look like a
|
||||
fresh log record are treated as continuations (the intent JSON can wrap
|
||||
across lines). Anything else closes/open the next record.
|
||||
"""
|
||||
records: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in lines:
|
||||
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
current = [line]
|
||||
elif current and not _LOG_RECORD_RE.match(line):
|
||||
current.append(line) # wrapped intent text
|
||||
else:
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
current = []
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
return records
|
||||
|
||||
|
||||
def foreground_from_logcat(lines: list[str]) -> str | None:
|
||||
"""The foreground package from the LAST valid START record, or None.
|
||||
|
||||
Non-START lines are ignored entirely; records whose intent carries no
|
||||
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
|
||||
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
|
||||
tolerated — the user prefix is never part of the cmp value.
|
||||
"""
|
||||
package: str | None = None
|
||||
for record in start_records(lines):
|
||||
match = _CMP_RE.search(record)
|
||||
if not match:
|
||||
continue
|
||||
candidate = match.group(1)
|
||||
if candidate and candidate != "null":
|
||||
package = candidate # last valid START wins
|
||||
return package
|
||||
|
||||
|
||||
def foreground_packages(lines: list[str]) -> list[str]:
|
||||
"""Every distinct valid START package in order (test seam)."""
|
||||
seen: list[str] = []
|
||||
for record in start_records(lines):
|
||||
match = _CMP_RE.search(record)
|
||||
if not match:
|
||||
continue
|
||||
candidate = match.group(1)
|
||||
if candidate and candidate != "null" and candidate not in seen:
|
||||
seen.append(candidate)
|
||||
return seen
|
||||
|
||||
|
||||
__all__ = [
|
||||
"foreground_from_logcat",
|
||||
"foreground_packages",
|
||||
"start_records",
|
||||
]
|
||||
@@ -4,12 +4,28 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
|
||||
information from any app using Android's MediaSession API.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaSessionInfo:
|
||||
"""Parsed media session data from Android."""
|
||||
"""Parsed media session data from Android.
|
||||
|
||||
Playback position fields come from the session's PlaybackState line:
|
||||
``position`` is the playback offset in ms as of ``updated`` — which is 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
|
||||
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
|
||||
artist: str | None = None
|
||||
@@ -17,13 +33,65 @@ class MediaSessionInfo:
|
||||
package: str | None = None
|
||||
playing: bool = False
|
||||
paused: bool = False
|
||||
buffering: bool = False
|
||||
position: float | None = None
|
||||
speed: float | None = None
|
||||
updated: int | None = None
|
||||
|
||||
def position_at_uptime(self, now_uptime_ms: float) -> float | None:
|
||||
"""Extrapolate the live playback position at device uptime ``now_uptime_ms``.
|
||||
|
||||
Mirrors HA core's convention: ``position + (now - updated) * speed``.
|
||||
Returns None when the session carries no position sample.
|
||||
"""
|
||||
if self.position is None or self.updated is None:
|
||||
return None
|
||||
speed = self.speed if self.speed is not None else 1.0
|
||||
return self.position + (now_uptime_ms - self.updated) * speed
|
||||
|
||||
|
||||
_PLAYBACK_STATE_RE = re.compile(r"PlaybackState\s*\{([^}]*)\}")
|
||||
|
||||
|
||||
def _parse_playback_state(content: str) -> dict[str, str]:
|
||||
"""Parse the ``key=value`` pairs inside a PlaybackState block.
|
||||
|
||||
Splitting on ", " (rather than searching for ``position=`` anywhere in
|
||||
the line) keeps the ``buffered position`` field from being mistaken for
|
||||
the real ``position``.
|
||||
"""
|
||||
fields: dict[str, str] = {}
|
||||
for part in content.split(", "):
|
||||
key, _, value = part.partition("=")
|
||||
if value:
|
||||
fields[key.strip()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
"""Parse `dumpsys media_session` output for ALL active media sessions.
|
||||
|
||||
Returns a list of every active session that has metadata or a known
|
||||
playback state, sorted: playing first, then paused, then others.
|
||||
playback state, sorted: playing first, then buffering, then paused, then
|
||||
others (t55).
|
||||
"""
|
||||
if not dump:
|
||||
return []
|
||||
@@ -31,7 +99,11 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
sessions: list[MediaSessionInfo] = []
|
||||
current_pkg: str | None = None
|
||||
in_active = False
|
||||
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
||||
# Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering
|
||||
current_state = 0
|
||||
current_position: float | None = None
|
||||
current_speed: float | None = None
|
||||
current_updated: int | None = None
|
||||
current_meta: MediaSessionInfo | None = None
|
||||
|
||||
for line in dump.split("\n"):
|
||||
@@ -47,16 +119,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
current_pkg = pkg[1].strip()
|
||||
in_active = False
|
||||
current_state = 0
|
||||
current_position = None
|
||||
current_speed = None
|
||||
current_updated = None
|
||||
current_meta = None
|
||||
|
||||
if "active=true" in stripped:
|
||||
in_active = True
|
||||
|
||||
if in_active and "state=PlaybackState" in stripped:
|
||||
if "state=3" in stripped:
|
||||
current_state = 3
|
||||
elif "state=2" in stripped:
|
||||
current_state = 2
|
||||
match = _PLAYBACK_STATE_RE.search(stripped)
|
||||
if match:
|
||||
fields = _parse_playback_state(match.group(1))
|
||||
try:
|
||||
current_state = int(fields.get("state", 0))
|
||||
except ValueError:
|
||||
current_state = 0
|
||||
current_position = _parse_float(fields.get("position"))
|
||||
current_speed = _parse_float(fields.get("speed"))
|
||||
current_updated = _parse_int(fields.get("updated"))
|
||||
|
||||
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
||||
desc_start = stripped.find("description=")
|
||||
@@ -68,7 +149,11 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
info = MediaSessionInfo()
|
||||
info.package = current_pkg
|
||||
info.playing = current_state == 3
|
||||
info.buffering = current_state == 6
|
||||
info.paused = current_state == 2
|
||||
info.position = current_position
|
||||
info.speed = current_speed
|
||||
info.updated = current_updated
|
||||
|
||||
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
||||
info.title = parts[0]
|
||||
@@ -85,18 +170,49 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
current_meta.package = current_pkg
|
||||
sessions.append(current_meta)
|
||||
|
||||
# Sort: playing first, then paused, then others
|
||||
# Sort: playing first, then buffering (mid-playback), then paused, then
|
||||
# others — a buffering session outranks a paused one (t55).
|
||||
def sort_key(s: MediaSessionInfo) -> int:
|
||||
if s.playing:
|
||||
return 0
|
||||
if s.paused:
|
||||
if s.buffering:
|
||||
return 1
|
||||
return 2
|
||||
if s.paused:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
sessions.sort(key=sort_key)
|
||||
return sessions
|
||||
|
||||
|
||||
def parse_audio_playback(dump: str) -> list[str]:
|
||||
"""Parse the "Audio playback (lastly played comes first)" section.
|
||||
|
||||
Returns the package names that recently played audio, in the dump's own
|
||||
order (most recently played first). This is a zero-cost hint from the same
|
||||
``dumpsys media_session`` call: an app shows up here even when it publishes
|
||||
no MediaSession (e.g. TiviMate, R2). Callers may filter system processes
|
||||
such as ``com.google.android.apps.mediashell`` (the Chromecast receiver).
|
||||
"""
|
||||
if not dump:
|
||||
return []
|
||||
|
||||
packages: list[str] = []
|
||||
in_audio = False
|
||||
for line in dump.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not in_audio:
|
||||
in_audio = "Audio playback" in stripped
|
||||
continue
|
||||
marker = "packages="
|
||||
if marker not in stripped:
|
||||
continue
|
||||
pkg_part = stripped.split(marker, 1)[1]
|
||||
for pkg in pkg_part.replace(",", " ").split():
|
||||
if pkg and pkg not in packages:
|
||||
packages.append(pkg)
|
||||
return packages
|
||||
|
||||
def parse_media_session(
|
||||
dump: str, foreground_package: str | None = None
|
||||
) -> MediaSessionInfo | None:
|
||||
@@ -105,8 +221,9 @@ def parse_media_session(
|
||||
Returns the best match from all active sessions:
|
||||
1. The session whose package matches the foreground app (if provided)
|
||||
2. The session that is currently playing (state=3)
|
||||
3. The session that is paused (state=2)
|
||||
4. Any active session with metadata
|
||||
3. The session that is buffering (state=6)
|
||||
4. The session that is paused (state=2)
|
||||
5. Any active session with metadata
|
||||
"""
|
||||
sessions = parse_all_media_sessions(dump)
|
||||
if not sessions:
|
||||
@@ -117,6 +234,5 @@ def parse_media_session(
|
||||
for s in sessions:
|
||||
if s.package == foreground_package:
|
||||
return s
|
||||
|
||||
# Already sorted: playing > paused > other
|
||||
# Already sorted: playing > buffering > paused > other
|
||||
return sessions[0]
|
||||
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
|
||||
recent_tasks: list[dict],
|
||||
media_sessions: list[MediaSessionInfo] | None = None,
|
||||
foreground_package: str | None = None,
|
||||
last_known: dict[str, MediaSessionInfo] | None = None,
|
||||
) -> list[dict] | None:
|
||||
"""Build a list of running app dicts from recent tasks + media sessions.
|
||||
|
||||
@@ -19,6 +20,9 @@ def running_apps_from_tasks(
|
||||
recent_tasks: List of {"package": str} from ADB recent tasks.
|
||||
media_sessions: Optional media sessions for playback state enrichment.
|
||||
foreground_package: The current foreground app package.
|
||||
last_known: Optional per-package TTL-filtered last-known-state cache
|
||||
(P3-3b) — apps without a live session carry their last known
|
||||
playback state as ``last_known_state``/``last_known_title``.
|
||||
|
||||
Returns None if there are fewer than 2 apps (no point showing a switcher
|
||||
with only one option).
|
||||
@@ -47,6 +51,8 @@ def running_apps_from_tasks(
|
||||
if session:
|
||||
if session.playing:
|
||||
app["state"] = "playing"
|
||||
elif session.buffering:
|
||||
app["state"] = "buffering"
|
||||
elif session.paused:
|
||||
app["state"] = "paused"
|
||||
else:
|
||||
@@ -57,6 +63,17 @@ def running_apps_from_tasks(
|
||||
app["artist"] = session.artist
|
||||
else:
|
||||
app["state"] = "running"
|
||||
# P3-3b: no live session — surface the TTL-capped last-known state
|
||||
cached = (last_known or {}).get(pkg)
|
||||
if cached is not None:
|
||||
if cached.playing:
|
||||
app["last_known_state"] = "playing"
|
||||
elif cached.buffering:
|
||||
app["last_known_state"] = "buffering"
|
||||
elif cached.paused:
|
||||
app["last_known_state"] = "paused"
|
||||
if cached.title:
|
||||
app["last_known_title"] = cached.title
|
||||
|
||||
apps.append(app)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Shared config helpers for the NVIDIA Shield integration."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdbConfig:
|
||||
"""Resolved ADB connection settings for a config entry."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
key_path: str
|
||||
|
||||
|
||||
def adb_key_path(hass: HomeAssistant) -> str:
|
||||
"""Path of the ADB private key for this integration (may not exist yet)."""
|
||||
return os.path.join(hass.config.config_dir, ".storage", DOMAIN, "adbkey")
|
||||
|
||||
|
||||
def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
|
||||
"""Local (certfile, keyfile) paths for the remote protocol client.
|
||||
|
||||
Mirrors core androidtv_remote's storage pattern: private material under
|
||||
``.storage/<domain>/``. Shared between config-flow pairing and runtime —
|
||||
an entry aborts duplicate setup by unique_id (= device MAC), so one pair
|
||||
of files suffices per install.
|
||||
"""
|
||||
base = os.path.join(hass.config.config_dir, ".storage", DOMAIN)
|
||||
os.makedirs(base, exist_ok=True)
|
||||
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||
|
||||
|
||||
def entry_host(entry) -> str:
|
||||
"""The Shield's address for an entry (t41: ONE field).
|
||||
|
||||
``options`` is the source of truth; ``data`` is read as a fallback so an
|
||||
entry created by an older flow (or a hand-written seed) still resolves.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip()
|
||||
|
||||
|
||||
def seed_host(block: dict) -> str:
|
||||
"""The address a YAML seed block carries (t41).
|
||||
|
||||
``host`` is the current key; the legacy ``protocol_host`` / ``adb_host``
|
||||
stay accepted so an existing configuration.yaml keeps seeding, protocol
|
||||
first (that transport is the one that cannot fall back to anything else).
|
||||
"""
|
||||
for key in (CONF_HOST, CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
value = str((block or {}).get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def seed_adb_enabled(block: dict) -> bool:
|
||||
"""Does a seed block ask for ADB? (t32 F4's shape rule, t41's keys).
|
||||
|
||||
An explicit ``adb_enabled`` wins. Otherwise an address that names the Shield
|
||||
— the current ``host`` key or the legacy ``adb_host`` — turns ADB on, while
|
||||
a protocol-only block leaves it off.
|
||||
"""
|
||||
block = block or {}
|
||||
if CONF_ADB_ENABLED in block:
|
||||
return bool(block[CONF_ADB_ENABLED])
|
||||
return bool(block.get(CONF_HOST) or block.get(CONF_ADB_HOST))
|
||||
|
||||
|
||||
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||
|
||||
Priority: the entry's own address (t41: no other integration is consulted)
|
||||
and its ADB port option.
|
||||
"""
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
return None
|
||||
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||
return AdbConfig(host=host, port=port, key_path=adb_key_path(hass))
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"domain": "nvidia_shield",
|
||||
"name": "NVIDIA Shield",
|
||||
"codeowners": [
|
||||
"@notexpectedyet"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
||||
"requirements": [
|
||||
"adb-shell>=0.4.0"
|
||||
],
|
||||
"version": "2026.10.08"
|
||||
"domain": "nvidia_shield",
|
||||
"name": "NVIDIA Shield",
|
||||
"codeowners": [
|
||||
"@notexpectedyet"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
||||
"requirements": [
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "2026.10.12",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
"integration_type": "device",
|
||||
"loggers": [
|
||||
"adb_shell",
|
||||
"androidtvremote2"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,224 @@
|
||||
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
|
||||
|
||||
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
|
||||
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
||||
|
||||
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
|
||||
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
|
||||
the core port's hold_secs+direction validation quirks are not carried over —
|
||||
long presses are expressed as the START_LONG/END_LONG pair instead;
|
||||
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
||||
``hold_secs`` parameter;
|
||||
- ``activity`` = the protocol current-app push (package; name resolution is
|
||||
deferred app-map work);
|
||||
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
||||
toggling when the state already matches).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.components.remote import RemoteEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
remote_command_script,
|
||||
)
|
||||
from .media_player import async_call_script, async_run_command_script, entity_available
|
||||
|
||||
_START_PREFIX = "START_LONG:"
|
||||
_END_PREFIX = "END_LONG:"
|
||||
_SHORT_PREFIX = "SHORT:"
|
||||
_POWER_KEY = "KEYCODE_POWER"
|
||||
|
||||
|
||||
def _split_command(command: str) -> tuple[str, str]:
|
||||
"""Key + direction from a command string (default SHORT)."""
|
||||
lowered = command.strip()
|
||||
if lowered.startswith(_START_PREFIX):
|
||||
return lowered[len(_START_PREFIX):], "START_LONG"
|
||||
if lowered.startswith(_END_PREFIX):
|
||||
return lowered[len(_END_PREFIX):], "END_LONG"
|
||||
if lowered.startswith(_SHORT_PREFIX):
|
||||
return lowered[len(_SHORT_PREFIX):], "SHORT"
|
||||
return lowered, "SHORT"
|
||||
|
||||
|
||||
def _key_code_name(command: str) -> str:
|
||||
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
|
||||
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
upper = command.upper()
|
||||
if upper.startswith("KEYCODE_"):
|
||||
return upper
|
||||
return upper # unknown names: the library reports/validates them
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the protocol-backed remote entity for this entry."""
|
||||
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
|
||||
|
||||
|
||||
class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
"""Remote entity over the manager's androidtvremote2 transport."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._manager: ShieldConnectionManager | None = getattr(
|
||||
entry, "runtime_data", None
|
||||
)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
||||
|
||||
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||
return getattr(self._entry, "runtime_data", None)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name=self._entry.title,
|
||||
manufacturer="NVIDIA",
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Available while ANY usable transport can serve a command (t40).
|
||||
|
||||
The same ladder as the media player (``entity_available``): protocol
|
||||
wired+available, ADB connected, fresh wake data or a passive manager all
|
||||
count as usable, and a passive manager stays available — nothing to
|
||||
transport is not a broken one.
|
||||
"""
|
||||
return entity_available(self._manager_of())
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
"""Current foreground app reported by the protocol push."""
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
return manager.current_app
|
||||
return None
|
||||
|
||||
@callback
|
||||
def _refresh_state(self) -> None:
|
||||
"""Manager store changed — re-evaluate state (activity/available)."""
|
||||
self._manager = self._manager_of()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
self._manager_unregistered = manager.register_listener(
|
||||
self._refresh_state
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
unregistered = getattr(self, "_manager_unregistered", None)
|
||||
if unregistered is not None:
|
||||
unregistered()
|
||||
self._manager_unregistered = None
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Wake the device — via the configured script, else the POWER key."""
|
||||
# t44: `turn_on` is one configuration, and the remote entity is the other
|
||||
# route to it; a script that owns the power sequence owns it here too.
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if manager.is_on is not False:
|
||||
# Unknown counts as "maybe on" — no superfluous toggle.
|
||||
return
|
||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Sleep the device — via the configured script, else the POWER key."""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if manager.is_on is not True:
|
||||
return
|
||||
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||
|
||||
async def async_send_command(
|
||||
self,
|
||||
command: list[str],
|
||||
num_repeats: int = 1,
|
||||
delay_secs: float = 0.0,
|
||||
hold_secs: float = 0.0,
|
||||
) -> None:
|
||||
"""Send protocol keys — or hand the whole command to the configured script.
|
||||
|
||||
t47: with a remote command script configured, it replaces the key path
|
||||
entirely and receives the service call's own parameters, named as they are
|
||||
on the service: ``command`` (a list of key commands), ``num_repeats``,
|
||||
``delay_secs`` and ``hold_secs``. Unset, nothing changes: repeats, long
|
||||
presses and delays go out as protocol keys exactly as before.
|
||||
"""
|
||||
script = remote_command_script(self._entry.options)
|
||||
if script:
|
||||
await async_call_script(
|
||||
self.hass,
|
||||
script,
|
||||
"remote.send_command",
|
||||
{
|
||||
SCRIPT_VAR_COMMAND: command,
|
||||
SCRIPT_VAR_NUM_REPEATS: num_repeats,
|
||||
SCRIPT_VAR_DELAY_SECS: delay_secs,
|
||||
SCRIPT_VAR_HOLD_SECS: hold_secs,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
for _ in range(max(1, num_repeats)):
|
||||
for cmd in command:
|
||||
key_part, direction = _split_command(cmd)
|
||||
key_name = _key_code_name(key_part)
|
||||
if hold_secs > 0 and direction == "SHORT":
|
||||
if await manager.send_protocol_key(key_name, "START_LONG"):
|
||||
await asyncio.sleep(max(0.0, hold_secs))
|
||||
await manager.send_protocol_key(key_name, "END_LONG")
|
||||
else:
|
||||
await manager.send_protocol_key(key_name, direction)
|
||||
if delay_secs > 0:
|
||||
await asyncio.sleep(delay_secs)
|
||||
|
||||
async def async_send_text(self, text: str) -> bool:
|
||||
"""Text entry (P5-2) via the protocol send_text (remote surface).
|
||||
|
||||
Falls back to ADB `input text` only when the protocol transport is
|
||||
down or the send failed (the Shield may swallow IME text while its
|
||||
on-screen keyboard is up, RESEARCH_IDEAS §2).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if manager is None or not text:
|
||||
return False
|
||||
if await manager.send_text_ime(text):
|
||||
return True
|
||||
return await manager.send_adb_input_text(text)
|
||||
@@ -4,17 +4,20 @@ import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
|
||||
from homeassistant.core import HomeAssistant, callback, Event
|
||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .media_player import resolve_app_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +29,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Nvidia Shield sensor entities."""
|
||||
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
|
||||
if entry.options.get(CONF_ADB_ENABLED, True):
|
||||
# discovery is an ADB feature; with the transport off there is nothing
|
||||
# to enumerate and the entity would only ever report "unavailable"
|
||||
entities.append(NvidiaShieldAppsSensor(hass, entry))
|
||||
|
||||
if entry.options.get(CONF_JELLYFIN_ENTITY):
|
||||
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
|
||||
@@ -38,6 +45,10 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
# G12 (t30): poll-driven attributes never belong in the database
|
||||
_unrecorded_attributes = frozenset(
|
||||
{"app_id", "app_name", "playback_state", "installed_apps"}
|
||||
)
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
@@ -60,49 +71,193 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
|
||||
|
||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
"""Current running app on the Shield."""
|
||||
"""Current running app on the Shield.
|
||||
|
||||
_attr_name = "Current App"
|
||||
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
||||
push, or the most recent visible ADB recent-task app when that has not
|
||||
reported (t41: our own transports only).
|
||||
"""
|
||||
|
||||
_attr_translation_key = "current_app"
|
||||
_attr_icon = "mdi:application"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||
|
||||
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:
|
||||
self._unsub_listeners.append(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
||||
)
|
||||
)
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
# Refresh when the connection manager updates its store.
|
||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||
|
||||
@callback
|
||||
def _handle_state_change(self, event: Event) -> None:
|
||||
def _handle_store_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _package(self) -> str | None:
|
||||
"""The raw package of the app that is on screen (t41, t55).
|
||||
|
||||
StateStore priority chain (P3-3c): protocol current_app push → ADB
|
||||
mResumedActivity (both via ``manager.foreground_app``) → the most
|
||||
recent visible ADB recent task. The recents tier is a last resort for
|
||||
a moment when neither live signal has reported yet; it keeps the
|
||||
sensor from going None while ADB is still warming up.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
return None
|
||||
foreground = manager.foreground_app
|
||||
if foreground:
|
||||
return foreground
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return running[0].get("package")
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if not state:
|
||||
return None
|
||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||
"""The resolved friendly name (t40), not the raw package id.
|
||||
|
||||
Exactly the ``media_player.app_name`` chain via the shared
|
||||
``resolve_app_name``: apps map → cached manifest label → the package
|
||||
text itself. A bare or unknown package shows its package text rather
|
||||
than None, like the media player's app_name.
|
||||
|
||||
This is why ``_package`` must be the CURRENT app and the label fetch
|
||||
must be driven off it (t55): the device-manifest label is resolved on
|
||||
demand for exactly the package passed here, so a live run showing a
|
||||
package id means the label tier never answered — see
|
||||
``ShieldConnectionManager.app_label`` for the caching rule that makes
|
||||
a miss retry instead of being stamped as an eternal "no label".
|
||||
"""
|
||||
manager = self._manager()
|
||||
return resolve_app_name(
|
||||
self._package(),
|
||||
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||
manager.app_label if manager is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if not state:
|
||||
return {}
|
||||
return {
|
||||
"app_id": state.attributes.get("app_id"),
|
||||
"app_name": state.attributes.get("app_name"),
|
||||
}
|
||||
"""The raw package stays visible here (t40) next to the shown name."""
|
||||
package = self._package()
|
||||
attrs: dict = {}
|
||||
if package:
|
||||
attrs["app_id"] = package
|
||||
name = self.native_value
|
||||
if name:
|
||||
attrs["app_name"] = name
|
||||
return attrs
|
||||
|
||||
|
||||
class NvidiaShieldAppsSensor(NvidiaShieldSensorBase):
|
||||
"""The apps installed on the Shield (t57) — a dashboard-facing surface.
|
||||
|
||||
The state is the app COUNT and the ``installed_apps`` attribute is the
|
||||
table: ``[{package, name, icon}, ...]``, exactly what the Configure page's
|
||||
Apps sub-screen edits. It is the answer to "it doesn't find all the apps":
|
||||
the list comes from the device (``pm list packages -3`` plus the active
|
||||
launcher), labelled through the cached ``pm dump`` path.
|
||||
|
||||
Only ADB-enabled entries get one. Discovery is a handful of shells, so it
|
||||
runs once when the entity is added and then only when the table has gone
|
||||
stale: ``async_update`` goes through the manager's TTL cache, so a poll on a
|
||||
fresh table issues NO shell. A sweep that could not run is NOT an answer
|
||||
(t62 F1): it is recorded as ``unknown`` — or shown as ``not_connected``
|
||||
while the transport itself is down — and retried on the next update instead
|
||||
of being frozen as a stamped-empty table for APP_LIST_TTL, while a
|
||||
sweep that ran and found nothing IS the answer ``ok`` with a count of 0. On
|
||||
top of that the user can ask for a refresh with the standard
|
||||
``homeassistant.update_entity`` action, and the Configure page runs the same
|
||||
sweep from its discovery row. With ADB off, or unreachable, the attributes
|
||||
say why and the state is 0 rather than raising.
|
||||
"""
|
||||
|
||||
_attr_translation_key = "installed_apps"
|
||||
_attr_icon = "mdi:apps"
|
||||
# Polling is what makes the standard `homeassistant.update_entity` action
|
||||
# work for it — the user-facing "refresh the app list" trigger. The poll is
|
||||
# free while the table is fresh: the manager returns the cache and issues no
|
||||
# shell until APP_LIST_TTL expires. A sweep that COULD NOT run leaves no
|
||||
# fresh table (t62 F1), so a later poll retries it — that retry is the
|
||||
# recovery the old stamped-empty answer blocked for up to six hours.
|
||||
_attr_should_poll = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps"
|
||||
|
||||
def _manager(self) -> ShieldConnectionManager | None:
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||
# one discovery per entity lifetime; a load is already a wake-up moment,
|
||||
# and nothing here runs on a state poll
|
||||
self.hass.async_create_task(self.async_update())
|
||||
|
||||
@callback
|
||||
def _handle_store_update(self) -> None:
|
||||
if self.hass is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Refresh the table when there is no fresh answer (t57, t62 F1).
|
||||
|
||||
HA's own poll (``_attr_should_poll``) and an explicit
|
||||
``homeassistant.update_entity`` both land here; the manager's TTL makes
|
||||
it a no-op while the table is fresh, and a retry while it is not —
|
||||
including after a sweep that could not run.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
return
|
||||
await manager.async_refresh_installed_apps()
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
manager = self._manager()
|
||||
return len(manager.installed_apps()) if manager is not None else 0
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The table itself, plus why it may be empty (never a crash).
|
||||
|
||||
``discovery`` is the honest reason (t62 F1): ``ok`` means the table IS
|
||||
the answer, including a genuinely empty one, while ``unknown`` means
|
||||
the last sweep could not run — so an empty list says nothing about the
|
||||
device and the next update retries.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is None:
|
||||
return {"installed_apps": [], "discovery": "unavailable"}
|
||||
apps = manager.installed_apps()
|
||||
attrs = {"installed_apps": apps}
|
||||
if apps:
|
||||
attrs["discovery"] = "ok"
|
||||
elif not self._entry.options.get(CONF_ADB_ENABLED, True):
|
||||
attrs["discovery"] = "adb_disabled"
|
||||
elif not manager.connected:
|
||||
attrs["discovery"] = "not_connected"
|
||||
else:
|
||||
# "ok" here is a real empty answer, "unknown" a sweep that could
|
||||
# not run, "pending" one that has not completed yet.
|
||||
attrs["discovery"] = manager.installed_apps_status
|
||||
return attrs
|
||||
|
||||
|
||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||
"""What's currently playing via Jellyfin."""
|
||||
|
||||
_attr_name = "Now Playing"
|
||||
_attr_translation_key = "now_playing"
|
||||
_attr_icon = "mdi:play-circle"
|
||||
|
||||
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,69 +3,315 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"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": {
|
||||
"name": "Device Name",
|
||||
"androidtv_entity": "Android TV Remote",
|
||||
"jellyfin_entity": "Jellyfin Media Player",
|
||||
"name": "Name",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
"power_entity": "Power Switch",
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
||||
"receiver_entity": "The receiver this device is connected to.",
|
||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"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": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_port": "ADB port number. Default is 5555."
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Finish setup",
|
||||
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
},
|
||||
"menu_options": {
|
||||
"receiver": "Receiver",
|
||||
"connection": "Connection",
|
||||
"adb_pair": "Set up ADB",
|
||||
"protocol_pair": "Pair the remote protocol",
|
||||
"finish": "Create the entry"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection",
|
||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Pair ADB",
|
||||
"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."
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — 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": {
|
||||
"title": "Pair the remote protocol",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"host": "The Shield's IP address",
|
||||
"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": {
|
||||
"title": "Re-pair Nvidia Shield",
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||
}
|
||||
},
|
||||
"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_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield entity is already configured."
|
||||
"already_configured": "This Shield is already set up.",
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed — 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": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NVIDIA Shield Settings",
|
||||
"description": "Configure linked devices and monitoring.",
|
||||
"title": "NVIDIA Shield settings",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||
"data": {
|
||||
"jellyfin_entity": "Jellyfin Media Player",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
"power_entity": "Power Switch",
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
||||
"receiver_entity": "The receiver this device is connected to.",
|
||||
"receiver_input": "Which input on the receiver.",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing — use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it — the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) — 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — 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,69 +3,315 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"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": {
|
||||
"name": "Device Name",
|
||||
"androidtv_entity": "Android TV Remote",
|
||||
"jellyfin_entity": "Jellyfin Media Player",
|
||||
"name": "Name",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
"power_entity": "Power Switch",
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
|
||||
"receiver_entity": "The receiver this device is connected to.",
|
||||
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"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": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_port": "ADB port number. Default is 5555."
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"title": "Finish setup",
|
||||
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
},
|
||||
"menu_options": {
|
||||
"receiver": "Receiver",
|
||||
"connection": "Connection",
|
||||
"adb_pair": "Set up ADB",
|
||||
"protocol_pair": "Pair the remote protocol",
|
||||
"finish": "Create the entry"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"title": "Receiver",
|
||||
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
|
||||
"data": {
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input"
|
||||
},
|
||||
"data_description": {
|
||||
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection",
|
||||
"description": "The Shield's address, the ADB port and which transports this entry uses.",
|
||||
"data": {
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Pair ADB",
|
||||
"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."
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — 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": {
|
||||
"title": "Pair the remote protocol",
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"host": "The Shield's IP address",
|
||||
"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": {
|
||||
"title": "Re-pair Nvidia Shield",
|
||||
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
|
||||
}
|
||||
},
|
||||
"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_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again — or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Shield entity is already configured."
|
||||
"already_configured": "This Shield is already set up.",
|
||||
"reauth_successful": "Re-paired successfully.",
|
||||
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
|
||||
"reauth_entry_missing": "This entry no longer exists.",
|
||||
"reconfigure_skipped": "Nothing changed — 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": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NVIDIA Shield Settings",
|
||||
"description": "Configure linked devices and monitoring.",
|
||||
"title": "NVIDIA Shield settings",
|
||||
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
|
||||
"data": {
|
||||
"jellyfin_entity": "Jellyfin Media Player",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
"power_entity": "Power Switch",
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"adb_host": "ADB Host",
|
||||
"adb_port": "ADB Port"
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
||||
"receiver_entity": "The receiver this device is connected to.",
|
||||
"receiver_input": "Which input on the receiver.",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing — use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it — the same opt-in posture as the core androidtv integration.",
|
||||
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) — 0.05 to 1.0 seconds (default 0.1, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# HA integration-standards audit — custom_components/nvidia_shield
|
||||
|
||||
Status: **COMPLETE.** Every claim is cited to a developer-docs page fetched during
|
||||
this audit (October 2026; the docs site shows "Last updated" stamps of Jun–Oct 2026
|
||||
on the pages cited). Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`,
|
||||
`RESEARCH_IDEAS.md`.
|
||||
|
||||
Sources audited (all fetched live):
|
||||
[Manifest](https://developers.home-assistant.io/docs/creating_integration_manifest/) (updated 2026-10-06) ·
|
||||
[Config flow](https://developers.home-assistant.io/docs/core/integration/config_flow/) ·
|
||||
[Options flow](https://developers.home-assistant.io/docs/core/integration/options_flow/) (updated 2026-09-17) ·
|
||||
[YAML configuration](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/) (updated 2026-09-17) ·
|
||||
[Config entries](https://developers.home-assistant.io/docs/config_entries_index/) (updated 2026-06-19) ·
|
||||
[Entity](https://developers.home-assistant.io/docs/core/entity/) ·
|
||||
[Internationalization](https://developers.home-assistant.io/docs/internationalization/core/) ·
|
||||
[Quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) + [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/) ·
|
||||
[Custom actions](https://developers.home-assistant.io/docs/dev_101_services/) ·
|
||||
[Diagnostics](https://developers.home-assistant.io/docs/core/integration/diagnostics/) ·
|
||||
[Tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/) ·
|
||||
[Deprecating (incl. YAML-drop pattern)](https://developers.home-assistant.io/docs/deprecating/) ·
|
||||
[ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) ·
|
||||
[Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/) ·
|
||||
[Listener+reload deprecation blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/) ·
|
||||
[BrowseMediaSource domain blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/) ·
|
||||
[Central config-flow abort reasons](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) ·
|
||||
[Always reload after reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) ·
|
||||
[HACS publish requirements](https://hacs.xyz/docs/publish/integration/)
|
||||
|
||||
Code audited: `manifest.json`, `strings.json`, `translations/en.json`,
|
||||
`__init__.py`, `config_flow.py`, `connection.py`, `media_player.py`,
|
||||
`remote.py`, `sensor.py`, `button.py`, `diagnostics.py`, `helpers.py`, tests/,
|
||||
repo root (`hacs.json`, README).
|
||||
|
||||
Legend: ✅ compliant · ⚠️ partial / should improve · ❌ gap (concrete fix in §3).
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Compliance checklist
|
||||
|
||||
### 1.1 Manifest ([docs](https://developers.home-assistant.io/docs/creating_integration_manifest/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `domain`, `name` naming rules | ✅ | `nvidia_shield` / "NVIDIA Shield" — local-only product, no suffix, per manifest doc's name rules |
|
||||
| `version` required for custom integrations (AwesomeVersion-compatible) | ✅ | `2026.10.08` (CalVer) — "The version of the integration is required for custom integrations" |
|
||||
| `documentation` URL | ✅ | points to the repo; for core submission it must be home-assistant.io (not applicable yet) |
|
||||
| `issue_tracker` | ✅ | present (HACS also lists it as required: [HACS publish](https://hacs.xyz/docs/publish/integration/)) |
|
||||
| `codeowners` | ✅ | `@notexpectedyet` |
|
||||
| `iot_class` from allowed set | ✅ | `local_push` — valid enum value; correct now that both the protocol push and the persistent ADB manager are the primary paths |
|
||||
| `config_flow: true` + `config_flow.py` exists | ✅ | |
|
||||
| `integration_type` | ⚠️ | **Not set.** Required for core integrations with a config flow; for custom it defaults to `hub` — but we are a single-device-per-entry integration, so it "should generally" be `device`. Explicit is recommended ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)). Gap G1 |
|
||||
| `requirements` | ✅ | `adb-shell>=0.4.0`, `androidtvremote2>=0.2.0`; "Custom integrations should only include requirements that are not required by Core" — both are non-core deps. Range-style (`>=`) is accepted for custom integrations (hassfest pins core integrations to `==`; range bounds are the pragmatic custom-integration choice) |
|
||||
| `dependencies` | ✅ | `[]` — we read Jellyfin state via the *already-configured* jellyfin integration and discover via the user's zeroconf service; neither is required to be a `dependencies` entry (manifest doc: dependencies guarantee the integration loads before setup, not that entries exist). G4 below covers the Jellyfin soft-dependency behavior if we ever import its API |
|
||||
| `zeroconf` discovery key | ✅ | `_androidtvremote2._tcp.local.` — exact-service-type entry, not in the "too generic" list |
|
||||
| `single_config_entry` | n/a | Multiple Shields are legitimate — do NOT set ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||
| `loggers` | ⚠️ | Not set. Recommended so users can raise `adb_shell`/`androidtvremote2` logging under our domain view; `adb_shell` (we wrap) and `androidtvremote2` are exactly the `getLogger` namespaces of our requirements. Gap G1 |
|
||||
| `quality_scale` | n/a | Only meaningful for core integrations; custom integrations can still self-audit (§1.9) but shouldn't fake the key ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||
| `import_executor` | ✅ | default (true) is right for us |
|
||||
| `disabled` | n/a | |
|
||||
|
||||
### 1.2 Config flow ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| Extends `ConfigFlow, domain=DOMAIN` in `config_flow.py` | ✅ | |
|
||||
| `VERSION` (+ migration for old entries) | ✅ | `VERSION = 2` with `async_migrate_entry` handling v1→v2 and rejecting downgrades (`config_entry.version > 2 → False`) — matches the migration contract ("return False → migration_error") |
|
||||
| Unique IDs: stable, string, not user-changeable; `_abort_if_unique_id_configured` | ⚠️ | Protocol path uses the **MAC from the server certificate** (accepted source, [docs list](https://developers.home-assistant.io/docs/core/integration/config_flow/)). But the fallback unique_id is the **`androidtv_entity` entity_id** — entity IDs are user-renameable, which makes them a weak unique_id source (docs list "Device Name / hostname that can be changed" as unacceptable; a renameable entity reference is the same class of problem). Gap G2 |
|
||||
| Discovery (zeroconf): set unique ID, abort `already_configured`, update host on IP change | ✅ | `async_step_zeroconf` sets MAC unique_id, aborts duplicates, and updates `protocol_host`/blank `adb_host` on IP change — this is the [gold `discovery-update-info`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery-update-info) behavior already implemented |
|
||||
| Discovery steps must never finish without user confirmation | ✅ | first-time discovery chains into the user step |
|
||||
| Discovery without unique ID → `_async_handle_discovery_without_unique_id()` | ❌ | not used on the no-MAC path; gap G2 (combine with the unique-id fallback fix) |
|
||||
| `is_matching` / `async_has_matching_flow` for ambiguous discovery | ✅-ish | MAC unique_id set in the flow aborts concurrent flows for the same device (docs: "the flow will immediately abort if another flow is in progress for this unique ID"); the MAC-less path is the only exposure (G2) |
|
||||
| Errors/aborts via strings keys (`errors["base"]`, `async_abort(reason=...)`) | ✅ | all keys exist in `strings.json` config.error/abort |
|
||||
| Interactive pairing (PIN) via flow steps | ✅ | `protocol_pair` uses `async_show_progress`-free two-step pattern (PIN prompt); acceptable — `async_show_progress`/`async_show_progress_done` are for long-running background work |
|
||||
| Reauth flow | ⚠️ | Steps exist (`async_step_reauth`/`reauth_confirm`), triggered via `entry.async_start_reauth(hass)` from the manager's invalid-auth callback ([config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/): reauth must update the entry and abort, not create). But `_finish_reauth` uses `async_update_entry` + `async_schedule_reload` — see G3 (deprecated combo) and [always-reload-after-reauth blog](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/); prefer `async_update_reload_and_abort(self._get_reauth_entry(), ...)`, and reauth flows need to be linked to a config entry ([2024-11-04 blog](https://developers.home-assistant.io/blog/2024/11/04/reauth-reconfigure-entry-id/) — ours gets it via `self.context["entry_id"]`, the mechanism the blog's helper replaces) |
|
||||
| Reconfigure step | ❌ | No `async_step_reconfigure`. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) says a reconfigure step lets users change **data** that is "not meant to be optional" without an OptionsFlow, and it is a [gold rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow). Our hosts live in options, so we are not broken — but G5 documents what a reconfigure step should own (device name + host moves + source toggles) |
|
||||
| Config-entry mutation only via `async_update_entry` | ✅ | everywhere ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/)) |
|
||||
| Full test coverage of config_flow.py ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/): "Integrations with a config flow require full test coverage of all code in config_flow.py") | ⚠️ | tests exist (`test_config_flow_pairing.py`, `test_config_flow_protocol.py`) but "full coverage" (also [bronze rule `config-flow-test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage)) needs a coverage run to confirm every step/branch; G6 |
|
||||
|
||||
### 1.3 Options flow ([docs](https://developers.home-assistant.io/docs/core/integration/options_flow/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `async_get_options_flow(config_entry) -> OptionsFlow()` **without constructor args**; entry via `self.config_entry` | ❌ | Ours: `NvidiaShieldOptionsFlow(config_entry)` stores `self._config_entry` — the old pattern. Current docs' handler takes **no arguments** and reads `self.config_entry`. Gap G3 |
|
||||
| Prefill via `self.add_suggested_values_to_schema(SCHEMA, self.config_entry.options)` | ⚠️ | We hand-roll `vol.Optional(key, default=current)` per field — works, but the documented helper is the single schema + suggested-values pattern; also lets us keep ONE static schema instead of conditionally building keys (ours rebuilds the schema dict each render, which also means fields disappear/reappear depending on state — legal but harder to test). G3 |
|
||||
| Update listener: `entry.async_on_unload(entry.add_update_listener(...))` | ✅ | registered in `async_setup_entry` ([options doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)) |
|
||||
| Reload-on-options: `OptionsFlowWithReload` OR update listener — **not both** | ❌ | We use the update listener (full reload on every options change, including pure-UI tweaks like app map); the current docs offer `OptionsFlowWithReload` for exactly this. Combined with G3 (the flow's own `async_schedule_reload` calls in reauth/zeroconf) this is the deprecated [listener+reloading-methods combo (error from 2026.12)](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/). Gap G3 |
|
||||
|
||||
### 1.4 strings.json / translations ([docs](https://developers.home-assistant.io/docs/internationalization/core/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `strings.json` adjacent to integration; `translations/en.json` for custom | ✅ | both present, same content |
|
||||
| `config` (steps/errors/aborts), `options` sections | ✅ | complete for every key we return |
|
||||
| Common-string references (`[%key:common::...%]`) | ⚠️ | e.g. `already_configured`, `reauth_successful` are common abort reasons — now [translated centrally](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) for shared reasons; harmless duplicated, minor cleanup |
|
||||
| `title` key | ⚠️ | optional ("fallback to manifest name"); omitted is fine |
|
||||
| `entity` section with `translation_key` on named entities | ⚠️ | Sensors use hard-coded English names (`_attr_name = "Current App"`, `"Now Playing"`). Entity doc: "Avoid hard coding a natural language name, use a translated name instead"; [gold rule `entity-translations`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations). Gap G7 |
|
||||
| `exceptions` section for `HomeAssistantError` translations | ❌ | we raise/log instead of raising translated exceptions in the action handler (see G8; also [raising exceptions](https://developers.home-assistant.io/docs/core/platform/raising_exceptions/) pattern) |
|
||||
| `services` section + `services.yaml` | ❌ | `nvidia_shield.adb_command` is registered dynamically with a schema but has **no `services.yaml` and no `services` strings** — see G8. Note [2025-10-24 blog](https://developers.home-assistant.io/blog/2025/10/24/service-web-api-changes/): service translations were removed from the WebSocket/REST service list — descriptions now live only in `services.yaml`/strings, making them required for frontend display |
|
||||
| `issues` section (repairs) | n/a | no repair issues yet (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues) — G9 suggestion for the ADB-unpaired state) |
|
||||
|
||||
### 1.5 Entity standards ([Entity doc](https://developers.home-assistant.io/docs/core/entity/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `has_entity_name = True` ([mandatory for new integrations](https://developers.home-assistant.io/docs/core/entity/)) | ✅ | all entities |
|
||||
| Main-feature entity: `_attr_name = None` (friendly_name = device name) | ✅ | media_player and remote |
|
||||
| Sub-entities named without device/type prefix | ✅-ish | "Current App", "Now Playing", "Pair ADB" — right shape, but hard-coded (G7) |
|
||||
| `unique_id` on every entity ([bronze `entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id)) | ✅ | `{DOMAIN}_{entry.entry_id}_{key}` — stable, not user-configurable |
|
||||
| `device_info` on all entities (automatic device registration) | ✅ | every platform; one device per entry keyed by `entry.entry_id`. ⚠️ model is hard-coded "Shield TV" — the protocol's `device_info` (model/vendor/sw_version) is available and populating `model`/`model_id`/`sw_version` from it is a cheap upgrade ([`model_id` added 2024-07](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)). G10 |
|
||||
| `should_poll` — push entities notify via `async_schedule_update_ha_state` / write | ✅ | `_attr_should_poll = False` everywhere + explicit `async_write_ha_state` on store events ([entity doc, subscribing pattern](https://developers.home-assistant.io/docs/core/entity/)) |
|
||||
| `available` ([silver `entity-unavailable`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unavailable)) | ✅ | media_player/remote/sensors derive from manager health |
|
||||
| Feature flags via `MediaPlayerEntityFeature` enum (no magic numbers — [deprecation](https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation/)) | ✅ | TURN_ON|TURN_OFF|VOLUME_STEP|VOLUME_MUTE|PLAY_MEDIA|BROWSE_MEDIA + playback features; **no `VOLUME_SET`** — correct per the protocol's capability ([RESEARCH_IDEAS §2 C-VOL](RESEARCH_IDEAS.md)) |
|
||||
| States via current `MediaPlayerState` enum | ✅ | imports the component enum; `STANDBY` not used ([deprecated 2025-07](https://developers.home-assistant.io/blog/2025/07/16/media-player-standby-state-deprecation/)) — our asleep→IDLE mapping is the compliant choice |
|
||||
| `entity_category` ([gold `entity-category`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) | ⚠️ | "Pair ADB" button is a device-configuration action → `EntityCategory.CONFIG` is the documented fit; sensors are primary. G11 |
|
||||
| `entity_registry_enabled_default` for noisy entities ([gold](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-disabled-by-default)) | ⚠️ | Now Playing is chatty (per-track state changes); consider `False` for the least-used mirror sensors only — judgment call, low priority |
|
||||
| `_unrecorded_attributes` for frequently-changing attributes ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | `extra_state_attributes` (media_player.py:804) carries session/transport details; media_player already unrecords its domain attributes, but our custom attributes that change with every poll should be listed. G12 |
|
||||
| Icon property discouraged vs icon translations ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | static `_attr_icon` is the discouraged-but-legal form; app-art uses `entity_picture` (fine). Icon translations only matter for gold |
|
||||
|
||||
### 1.6 Setup / unload / lifecycle ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `entry.runtime_data` for runtime objects ([bronze `runtime-data`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data)) | ✅ | `ShieldConnectionManager` on `entry.runtime_data` |
|
||||
| `async_forward_entry_setups` + platform `async_setup_entry` | ✅ | |
|
||||
| `async_unload_entry` forwards unload + cleans up listeners/connections | ✅ | manager stopped, hass.data popped; entities unsubscribe in `async_will_remove_from_hass` |
|
||||
| `EVENT_HOMEASSISTANT_STOP` closes connections | ✅ | via `entry.async_on_unload(bus.async_listen_once(...))` |
|
||||
| `hass.data[DOMAIN][entry_id]` usage | ⚠️ | we still store the entry there for cross-module lookups; fine, but the manager on `entry.runtime_data` makes most consumers redundant — trim opportunistically |
|
||||
| Test-before-setup ([bronze](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup)) | ⚠️ deliberate | We intentionally never raise `ConfigEntryNotReady` (ADB may be unpaired; manager retries in background). This is a justified deviation — the rule's goal (avoid "unknown error" states) is met by the passive-start design + Pair ADB button + protocol `keep_reconnecting` — but it should be **documented** (e.g. a `quality_scale.yaml`-style note or README paragraph). G13 |
|
||||
|
||||
### 1.7 Actions (services) ([docs](https://developers.home-assistant.io/docs/dev_101_services/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| Register actions in `async_setup` — "Service actions should always be registered" so they exist even with no loaded entries | ❌ | `adb_command` is registered lazily from the media_player platform when the first entry opts in, and removed when the last unloads. That violates the documented posture (always registered; the doc's rationale: editable/validatable automations + informative errors). Gap G8 |
|
||||
| `services.yaml` describing fields/target | ❌ | missing entirely (G8) |
|
||||
| Names/descriptions from translations ([i18n doc](https://developers.home-assistant.io/docs/internationalization/core/), `services` key) | ❌ | none (G8) |
|
||||
| Target the right level; **target must not be optional** ("Do not implement a default target when none is specified") | ⚠️ | our schema has `Optional("entity_id")` with implicit all-entries fallback — non-compliant targeting. G8 |
|
||||
| Errors raised as exceptions with translations ([silver `action-exceptions`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions); [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) | ❌ | logs warnings on failure instead of raising `ServiceValidationError(translation_domain=...)`. G8 |
|
||||
|
||||
### 1.8 Diagnostics ([docs](https://developers.home-assistant.io/docs/core/integration/diagnostics/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| `async_get_config_entry_diagnostics` | ✅ | correct module-level function (diagnostics.py — previously misplaced inside media_player.py and fixed per t20/t21 finding) |
|
||||
| Redaction of sensitive data via `async_redact_data` | ⚠️ | we ship a hand-rolled `_redact` with marker matching; the doc points at the `async_redact_data` helper with an explicit TO_REDACT list — the helper is the convention (defensive marker-redaction is a reasonable extra on top, but config fields like host should use the helper list). G14 |
|
||||
| Device diagnostics ([gold `diagnostics`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics)) | ⚠️ | config-entry only; device diagnostics is the gold-tier add (single-device integration → mostly redundant, note why if skipped) |
|
||||
|
||||
### 1.9 Tests ([tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/))
|
||||
|
||||
| Requirement | Us | Notes |
|
||||
|---|---|---|
|
||||
| Tests per domain under `tests/`, `conftest.py`, `test_init.py` for setup/reload/unload | ⚠️ | `tests/` exists with focused files; there is no `test_init.py` covering setup/reload/unload/migration of the entry (the structure doc names `test_init.py` for exactly that). G6 |
|
||||
| Config-flow full coverage | ⚠️ | see G6 |
|
||||
| Coverage bar for silver: >95% ([`test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage)) | ⚠️ | run pytest --cov to baseline; vendored `ha_adb_common` counts toward it |
|
||||
|
||||
### 1.10 Documentation / quality scale ([quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/), [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/))
|
||||
|
||||
**Bronze** (baseline for all new integrations): config-flow ✅ · entity-unique-id ✅ · has-entity-name ✅ · runtime-data ✅ · test-before-configure ✅ (both pairing steps test the connection) · unique-config-entry ✅ (MAC path) · action-setup ❌ (G8) · config-flow-test-coverage ⚠️ (G6) · brands ⚠️ (G15: no `brand/` icon in repo; HACS requires brand assets) · docs rules (G16: README has setup docs; needs explicit removal instructions + action descriptions to satisfy `docs-high-level-description`, `docs-installation-instructions`, `docs-removal-instructions`, `docs-actions`).
|
||||
|
||||
**Silver** adds: config-entry-unloading ✅ · reauthentication-flow ✅ (with G3 fix) · entity-unavailable ✅ · log-when-unavailable ⚠️ (verify we log once per transition, not every retry — the manager's backoff looks right; add a test) · parallel-updates ⚠️ (entities are push; declare/verify `PARALLEL_UPDATES` where the platform defaults matter — G12 note) · action-exceptions ❌ (G8) · test-coverage ⚠️ (G6) · docs-configuration-parameters ⚠️ (README should describe every option; strings.json is the source of truth today) · integration-owner ✅ (codeowner declared).
|
||||
|
||||
**Gold** adds (for reference, not required): discovery ✅ (+update-info ✅) · devices ✅ · diagnostics ✅ · entity-category/entity-translations/icon-translations ❌ (G7/G11) · reconfiguration-flow ❌ (G5) · repair-issues ❌ (G9) · extensive docs ❌ (G16).
|
||||
|
||||
## Part 2 — Optional YAML configuration alongside the config flow: current doctrine and canonical pattern
|
||||
|
||||
### 2.1 The rule
|
||||
|
||||
- [ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) (binding): *"Integrations that communicate with devices and/or services are only configured via the UI. In rare cases, we can make an exception."*
|
||||
- Current [YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/): same rule restated — *"Integrations that communicate with devices and/or services are configured via a config flow. In rare cases, we can make an exception. Existing integrations that should not have a YAML configuration are allowed and encouraged to implement a configuration flow and remove YAML support. **Changes to existing YAML configuration for these same existing integrations will no longer be accepted.**"* Also: *new integrations must not use platform-key YAML* (ADR-0007); any YAML must live under the integration's own domain key.
|
||||
- So: for our device integration, YAML is allowed **only** as an *import source* that ends in a real config entry (`source: import`), never as a parallel runtime configuration path. The `import` step name is [reserved for exactly this](https://developers.home-assistant.io/docs/core/integration/config_flow/).
|
||||
|
||||
### 2.2 The as-documented pattern ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/), "Dropping YAML support for an integration")
|
||||
|
||||
The current docs' canonical wiring (shown there in the context of deprecating YAML, and it is the same seeding mechanism an *optional* import uses):
|
||||
|
||||
1. **`__init__.py`: `async_setup(hass, config)` reads the domain key and seeds the import flow.** The exact documented snippet:
|
||||
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the integration."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
(`SOURCE_IMPORT` from `homeassistant.config_entries`; this is the current documented form — the deprecated `discovery` step and platform-key imports are gone.)
|
||||
|
||||
2. **`config_flow.py`: `async_step_import` (step name `import`) validates the YAML dict, reuses the same entry-creation path as `async_step_user` (unique_id, `_abort_if_unique_id_configured`, `_create_entry`).** Resulting entries carry `source: import` and behave exactly like UI entries thereafter — options flow, reload, unload all apply. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) reserves the step name; [config entries doc](https://developers.home-assistant.io/docs/config_entries_index/) treats imported entries as normal config entries.
|
||||
|
||||
3. **Add a `CONFIG_SCHEMA`** for the domain-key YAML ([YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/), CONFIG_SCHEMA section) using probatio/vol schema, and re-use the config-flow validation so YAML and UI stay consistent.
|
||||
|
||||
4. If YAML support is ever *dropped* (not our case), the documented procedure is: import step + the core `deprecated_yaml` repair issue with `HOMEASSISTANT_DOMAIN`, `breaks_in_ha_version`, then leave `cv.config_entry_only_config_schema(DOMAIN)` behind ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/)).
|
||||
|
||||
### 2.3 What cannot be imported (honest limits)
|
||||
|
||||
- **Interactive pairing cannot happen during import.** An import step produces a config entry from static YAML data — it cannot wait for a human to approve an ADB prompt or type a PIN shown on the Shield. Our design already anticipates this: **ADB key approval is intentionally decoupled from setup** (the manager auto-connects only when a key exists; the Pair ADB button + flow step handle approval), so an imported entry can start unpaired and become functional after the user pairs. For the **protocol (PIN) pairing** there is currently no post-setup trigger — an imported entry with `protocol_enabled` cannot complete pairing without re-running the config flow. Options: keep protocol pairing out of the YAML surface (recommended), or add a repair issue/button that starts a reauth-style protocol-pair flow for unpaired entries (G9).
|
||||
- **Discovery-derived values (MAC unique_id) cannot come from YAML.** The import can set a *user-chosen* unique_id (e.g. the entity reference) or none; a MAC-less entry weakens duplicate-protection (see G2) — the import should require the `androidtv_entity` reference or an explicit `unique_id`-grade value.
|
||||
- **`source: import` entries show in the UI** and can be migrated to `source: user` only by deletion + re-add; document that YAML edits require deleting/re-importing (or honoring `async_update_entry` on re-import with `reason: options_updated` style updates — simplest is abort `already_configured` on re-import, matching most core integrations).
|
||||
|
||||
### 2.4 HACS / custom-integration policy view
|
||||
|
||||
- [HACS publish requirements](https://hacs.xyz/docs/publish/integration/) do not mention YAML at all — they require repo structure (one integration under `custom_components/`), the six manifest keys (we have all: domain, documentation, issue_tracker, codeowners, name, version), and **brand assets** (an `icon.png` under `brand/` or the brands repo — we currently lack it, G15). HACS never *forbids* an import path; it is simply orthogonal.
|
||||
- The HA project position ([ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md)) is that contributors may drop YAML to reduce burden "power to our contributors" — i.e. no project-level pressure to provide YAML for device integrations, and the quality scale contains no YAML rule. **Recommendation: treat YAML import as optional sugar; do not build it for the MVP** — the UI flow already covers discovery + pairing, and the import path buys only static-foreignKey convenience at the cost of the G2/G9 caveats.
|
||||
|
||||
## Part 3 — Gap list (concrete, file-anchored)
|
||||
|
||||
Ordered by severity; each item names the file(s) and the change.
|
||||
|
||||
| # | Severity | Gap → Fix |
|
||||
|---|---|---|
|
||||
| G3a | **High (breaks 2026.12)** | Deprecated listener+reloading combo: `config_flow.py` `_finish_reauth` (`async_update_entry` + `async_schedule_reload`) and the zeroconf host-update path (`async_update_entry` + `async_schedule_reload`) both double-reload because `__init__.py` registers `entry.add_update_listener(_async_update_listener)` (full reload). Per the [2026-05-07 blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/): in `config_flow.py` replace reauth's pair with `self.async_update_reload_and_abort(self._get_reauth_entry(), data_updates={...})`; for zeroconf use `async_update_entry` only and let the listener reload (or `reload_on_update=False` on `_abort_if_unique_id_configured`). Also see [always-reload-after-reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) |
|
||||
| G3b | **High (deprecation)** | `config_flow.py` `NvidiaShieldOptionsFlow(config_entry)`: constructor must take no args — `async_get_options_flow(config_entry) -> NvidiaShieldOptionsFlow()` reading `self.config_entry` ([options flow doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)); convert hand-rolled defaults to `add_suggested_values_to_schema`; consider `OptionsFlowWithReload` and dropping the update listener (or keep listener for partial reloads of non-structural options — but then never schedule reloads inside the flow) |
|
||||
| G8 | High (silver) | `media_player.py` `_adb_command_service_ensure/_maybe_remove` + missing files: move registration to `__init__.py` `async_setup` (always registered, with `option-dependent` handler that no-ops/logs when no entry opted in); add `services.yaml` (fields: `command` required, `entity_id` **required** target — [targeting rules](https://developers.home-assistant.io/docs/dev_101_services/): target must not be optional; consider `device_id`/config_entry targeting instead of entity); add `strings.json` `services` section (name/description/fields) + `icons.json` service icon; replace warning-logs with `ServiceValidationError(translation_domain=DOMAIN, translation_key=...)` + `exceptions` section ([action-exceptions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions), [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) |
|
||||
| G2 | High | Unique-ID source fallback: `config_flow.py` uses `androidtv_entity` (entity_id) as unique_id when no MAC exists — renameable/user-changeable → weak. Fix: on the protocol path always MAC; on ADB-only setups without protocol, use `await self._async_handle_discovery_without_unique_id()`-grade handling for discovery, and for manual setup keep the entity-based ID but document the rename caveat, or derive from the device's MAC via the entity's device registry entry (device registry holds `CONNECTION_NETWORK_MAC`) |
|
||||
| G1 | Medium | `manifest.json`: add `"integration_type": "device"`, `"loggers": ["adb_shell", "androidtvremote2"]` ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||
| G6 | Medium | Tests: add `tests/test_init.py` (setup/reload/unload/migration) per the [tests structure doc](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/); run coverage against `config_flow.py` to satisfy [config-flow-test-coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage) |
|
||||
| G7 | Medium | Entity naming: sensors `sensor.py` "Current App"/"Now Playing" — replace `_attr_name` with `translation_key` + `entity.sensor.*` names in `strings.json` ([entity naming](https://developers.home-assistant.io/docs/core/entity/), [entity-translations](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations)); button "Pair ADB" likewise |
|
||||
| G9 | Medium | Unpaired/needs-pairing UX: raise a repair issue (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues)) with an `issues` strings section when the protocol transport is enabled but unpaired, linking to the reauth/pair flow — this also gives YAML-imported entries a pairing path (see §2.3) |
|
||||
| G10 | Low | `media_player.py` `device_info`: populate `model`/`model_id`/`sw_version` from the protocol `device_info` push (available post-pairing; [model_id blog](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)) |
|
||||
| G14 | Low | `diagnostics.py`: switch `_redact` to `async_redact_data` with an explicit TO_REDACT list for `entry.data/options` ([diagnostics doc](https://developers.home-assistant.io/docs/core/integration/diagnostics/)), keeping the marker-based defense as a second layer |
|
||||
| G11 | Low | `button.py`: `_attr_entity_category = EntityCategory.CONFIG` on Pair ADB ([entity-category rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) |
|
||||
| G12 | Low | `media_player.py`: declare `_unrecorded_attributes` (class attribute) for fast-changing custom attributes; verify silver `log-when-unavailable` once-per-transition behavior in `connection.py` with a test ([entity doc](https://developers.home-assistant.io/docs/core/entity/), [log-when-unavailable](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/log-when-unavailable)) |
|
||||
| G16 | Low | Docs for bronze: README needs a removal section + per-action documentation ([docs-removal-instructions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-removal-instructions), [docs-actions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-actions)) |
|
||||
| G15 | Low | Brand assets: add `brand/icon.png` to the repo (HACS requirement, [HACS publish](https://hacs.xyz/docs/publish/integration/)) and add the integration to the [brands repo](https://developers.home-assistant.io/docs/creating_integration_brand/) |
|
||||
| G17 | Info | Schema library: `config_flow.py`/`media_player.py` import `voluptuous` — **no change required** ([Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/): HA aliases the import; voluptuous is no longer installed but the alias resolves; the ban is core-only). New code may import `probatio` directly; opportunistically migrate |
|
||||
| G18 | Info | BrowseMedia: `media_player.py` `async_browse_media` constructs `BrowseMediaSource` without `domain` — `domain` is now a **required** `str` on `BrowseMediaSource.__init__` ([2026-05-20 blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/)). Verify the media_player-component helper we import and pass `domain=DOMAIN` (children) / the integration domain (root); our `media_content_type="apps"` root is not a `RootBrowseMediaSource` case |
|
||||
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
|
||||
| G4 | Low | Jellyfin auto-detect silently fails when the Jellyfin integration is not yet configured (`config_flow.py` `_find_jellyfin_entity` finds no entities). We deliberately do not list `jellyfin` in `dependencies` (nothing breaks without it); document the ordering caveat in the README instead ([manifest doc, dependencies semantics](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
|
||||
| G13 | Low | Document the deliberate deviation from [test-before-setup](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup): a README paragraph (or an exempted-rule note) explaining that entries start passively without `ConfigEntryNotReady` because ADB may be unpaired, with the Pair ADB button + protocol `keep_reconnecting` + reauth as the recovery UX |
|
||||
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
|
||||
### Explicitly OK as-is (no action)
|
||||
|
||||
- `iot_class: local_push`, requirement ranges, zeroconf entry, `version` (CalVer), `config_flow`, repo structure for HACS (one integration dir), `hacs.json`, devices per entry, feature-flag enums, `MediaPlayerState` usage, `entry.runtime_data`, unload/stop lifecycle, migration versioning shape, entity availability model, and the deliberate no-`ConfigEntryNotReady` design (document it, G13).
|
||||
- Voluptuous imports remain valid through the probatio alias (G17).
|
||||
+32
-15
@@ -110,13 +110,17 @@ make logcat a true push stream over `adb_shell` for MVP.**
|
||||
|
||||
### Why not pure logcat push (yet)
|
||||
|
||||
`adb_shell`'s `AdbDeviceTcpAsync.shell()` is **request → full-response**; there is no
|
||||
incremental/streaming read API on the stable surface. Running `exec logcat` blocks
|
||||
forever. A true stream means either hacking adb_shell internals (fragile across its
|
||||
releases) or adding a dependency (violates constraints). Also, logcat does not
|
||||
reliably carry *playback state* changes for third-party apps — it reliably carries
|
||||
**foreground/app lifecycle** events (verified R8: `ActivityTaskManager: START u0
|
||||
{… cmp=<pkg>/<activity>}` on every app open, including home-button → launcher).
|
||||
CORRECTION (C-STREAM, spike-verified on our pinned range): `adb_shell` 0.4.4
|
||||
**does** ship `streaming_shell` — a generator yielding each line of output — on
|
||||
both the sync `AdbDevice` and async `AdbDeviceAsync` (async needs the tiny
|
||||
`aiofiles` + `async_timeout` extras, both already HA-ecosystem staples). So a
|
||||
true logcat push stream needs neither internals hacks nor a new major
|
||||
dependency. What keeps the hybrid design honest is *device behavior*, not the
|
||||
library: logcat does not reliably carry *playback state* for third-party apps —
|
||||
it reliably carries **foreground/app lifecycle** events (verified R8:
|
||||
`ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` on every app open,
|
||||
including home-button → launcher). Streaming stability over multi-hour
|
||||
connections still needs a device spike before we make it the sole trigger.
|
||||
|
||||
### The hybrid design (target architecture)
|
||||
|
||||
@@ -128,8 +132,10 @@ entry.runtime_data = ShieldConnectionManager
|
||||
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
|
||||
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
|
||||
├─ trigger listener: foreground-change events
|
||||
│ (logcat -d -T1 ActivityTaskManager delta snapshots ~2–5 s cadence;
|
||||
│ Phase 3 option: internals streaming if adb_shell ever supports it)
|
||||
├─ trigger listener: foreground-change events
|
||||
│ Phase 1: logcat delta snapshots ~2–5 s cadence (safe default)
|
||||
│ Phase 2+: streaming_shell logcat stream on a dedicated connection
|
||||
│ (library support verified on 0.4.4; pending device stability spike)
|
||||
├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
|
||||
│ → if changed → `dumpsys media_session` fetch
|
||||
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
|
||||
@@ -267,10 +273,14 @@ Files:
|
||||
- `remote.py` (new platform) — protocol-backed remote entity (port of core:
|
||||
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
|
||||
current app).
|
||||
- `media_player.py` — command routing switches to protocol keys; volume gains
|
||||
absolute level (VOLUME_SET) + real mute state; availability and is_on come
|
||||
- `media_player.py` — command routing switches to protocol keys; volume gains a
|
||||
push-based level readout + real mute state (NOTE: androidtvremote2 has NO
|
||||
absolute volume set — HA core closed volume_set for androidtv_remote as
|
||||
not_planned; only VOLUME_STEP | VOLUME_MUTE are claimed, level comes from
|
||||
volume_info push); availability and is_on come
|
||||
from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
|
||||
plug off.
|
||||
plug off.
|
||||
- `media_player.py` `async_play_media` — §7 channel port via protocol
|
||||
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
|
||||
fallback when the protocol is down.
|
||||
@@ -329,16 +339,23 @@ tests cover all ladder branches.
|
||||
|
||||
Files:
|
||||
- Deep links — now cheap: `media_type: app/url` → protocol
|
||||
`send_launch_app_command` (port of core pattern) + BrowseMedia app directory;
|
||||
`send_launch_app_command` (port of core pattern; the client auto-prefixes
|
||||
`market://launch?id=` for bare package ids — C-LAUNCH, verified in the client
|
||||
source, so no extra branch for app-id vs URI) + BrowseMedia app directory;
|
||||
per-app `am start` extras via ADB only for apps the protocol can't launch
|
||||
(data-driven map in const).
|
||||
(data-driven map in const; discovery recipe via
|
||||
`adb shell cmd package dump <pkg>` in RESEARCH_IDEAS.md).
|
||||
- Channel keys: add `channel_up`/`channel_down` (proto keycodes 166/167 —
|
||||
trivial, no UI needed) alongside the §7 digit path.
|
||||
- Options: apps name/icon map editor (core androidtv_remote pattern), digit
|
||||
delay, IME toggle, privacy scope; finish config entry migration if needed.
|
||||
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
|
||||
IP change; `manifest.json` gains the `zeroconf` integration dependency.
|
||||
- `adb_command` service + diagnostics (port androidtv patterns).
|
||||
- Optional: logcat streaming via adb_shell internals if verified stable (behind
|
||||
an option flag); otherwise keep snapshot approach.
|
||||
- Optional: streaming_shell logcat stream as the trigger source (library
|
||||
support verified on adb-shell 0.4.4, spike at goal round 9; pending a
|
||||
multi-hour device stability spike) behind an option flag; default stays
|
||||
delta snapshots.
|
||||
- Trim: fold Current App sensor if StateStore covers it; drop
|
||||
`running_apps_from_sessions` legacy; remove transitional external-entity code.
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Research ideas for ha-nvidia-shield (features, patterns, TiviMate)
|
||||
|
||||
Status: **COMPLETE.** Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`.
|
||||
Method: primary sources first — library/integration source code (androidtvremote2,
|
||||
adb_shell, HA core), HA core GitHub issues/PRs, home-assistant.io integration docs,
|
||||
home-assistant.io action docs, and community threads. Citations are inline markdown
|
||||
links. Note: HA's Action *classes* (`MediaPlayerEntityFeature.*`) are what we set in
|
||||
code; HA's *Actions* (`media_player.play_media` etc.) are what users call.
|
||||
|
||||
---
|
||||
|
||||
## 1. What users actually request (from HA core issues, community threads, library issues)
|
||||
|
||||
| # | Request | Source | Take for our MVP |
|
||||
|---|---|---|---|
|
||||
| U1 | Absolute volume set on Android TV players — `media_player.volume_set` repeatedly requested; HA core androidtv_remote closed it **not_planned** | [core#176505](https://github.com/home-assistant/core/issues/176505) | High demand, but the protocol v2 proto has **no usable volume-set primitive shipped** (it reports volume; see C-VOL). Our Phase 2 now ships `VOLUME_STEP|MUTE` + level readout (captain accepted; plan already fixed). If demand ever recurs, the honest fallback is an ADB `input keyevent KEYCODE_VOLUME_*` step loop behind a helper script — **not** a fake supported feature |
|
||||
| U2 | Launching *any* installed app (not just deep-linkable ones) and retrieving the installed-app list | [androidtvremote2#17](https://github.com/tronikos/androidtvremote2/issues/17) | The official NVIDIA Shield app *does* retrieve the app list over another service; androidtvremote2 accepted `market://launch?id=<pkg>` as the app-launch workaround (now built into `send_launch_app_command`, see C-LAUNCH). App-list *retrieval* is not implementable through this client's protocol surface — a data-driven, options-defined app map is the right MVP substitute (already in plan) |
|
||||
| U3 | Text/IME entry (typing into search boxes); behavior **depends on the app and on whether the on-screen keyboard is open** (one-at-a-time chars get replaced in Spotify; whole-string send works) | [androidtvremote2#32](https://github.com/tronikos/androidtvremote2/issues/32) | Library supports `send_text` but behavior is app-dependent. Keep the IME option **default off** (as planned) and document the qualifier rather than trying to be clever |
|
||||
| U4 | Voice search / voice commands | [androidtvremote2#28](https://github.com/tronikos/androidtvremote2/issues/28) — closed; the library now ships `start_voice()`/`VoiceStream`, gated by `enable_voice` and device feature support | Implemented in the lib (PCM 16-bit mono 8 kHz streamed as `RemoteVoiceBegin/Payload/End`). Post-MVP only; note exists so Phase 4 does not re-research |
|
||||
| U5 | Deep-link launching of apps into specific sections (community guide table with ~60 known app URLs, plus how to discover one via `adb shell cmd package dump PACKAGE` and manifest string resolvers) | [deep-link guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) | Directly validates Phase-4 deep links; `send_launch_app_command` accepts either a deep link or a bare package id (see C-LAUNCH). The guide's discovery recipe (`cmd package dump <pkg>`, grep `<data`, resolve `@string/deeplink_schema`) is worth copying into our docs/contributor workflow |
|
||||
| U6 | BrowseMedia containing installed apps / activity list so users can launch apps from the media panel instead of scripts | [core#117126](https://github.com/home-assistant/core/pull/117126) (androidtv_remote: "Implement Android TV Remote browse media with apps and activity list") | Validates Phase-4 BrowseMedia app directory; can be built from our own running_apps data or an options-defined list (no protocol app-list needed) |
|
||||
| U7 | Channel-by-number play_media + channel up/down keys on streaming players (Roku, Bravia) | [Roku docs](https://www.home-assistant.io/integrations/roku/), [Bravia docs](https://www.home-assistant.io/integrations/braviatv/) | `play_media(media_content_type: channel)` is the canonical way every TV integration does tuning (Roku, Bravia); our §7 digit path matches. Bonus Roku/Bravia idea: expose `channel_up`/`channel_down` as remote commands (protocol `KEYCODE_CHANNEL_UP=166` / `KEYCODE_CHANNEL_DOWN=167` — in the proto, no new work) |
|
||||
| U8 | TiviMate: start it and switch to a specific channel from an HA automation | [community thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) (2025, active) | **Exactly our §7 play_media(channel) requirement.** The working recipe there: `remote.turn_on` with `activity: ar.tvplayer.tv` → delay ~7 s (app cold-start) → `media_player.play_media` with `media_content_type: channel` / `media_content_id: "1"` — note the wait; our play_media should not promise cold-start-to-channel in one shot (we don't own app launch speed). Follow-up request in the thread shows demand for tuning VOD/series pages too (unresolved anywhere) |
|
||||
| U9 | Shield app-launcher cards for wall-panel remotes (Firemote adds TiviMate/SmartTube shortcuts); Harmony-style activity switching | [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48), [Shield launcher blueprint](https://community.home-assistant.io/t/shield-app-launcher-for-harmony-remote/858225) | QoL signal for Phase-4 apps editor + `source_list`; also shows app-name→icon mapping is what users iterate on (supports data-driven app map + icon support) |
|
||||
| U10 | Firemote / universal-remote-card users must hand-define every app shortcut because the Shield integration exposes no app directory | [universal-remote-card discussion](https://github.com/Nerwyn/universal-remote-card/discussions/52) | Independent confirmation of U6/U9 demand; [TiviMate + SmartTube shortcut request](https://github.com/PRProd/HA-Firemote/issues/48) |
|
||||
|
||||
Also verified from an independent direction: [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
|
||||
confirms TiviMate "doesn't capture anything" for notification-based playback
|
||||
detection on the Shield — consistent with our R2 no-media-session finding, and a
|
||||
reminder that projects tried and failed to get sessions out of TiviMate
|
||||
(see §5).
|
||||
|
||||
## 2. androidtvremote2 capabilities we are not using (verified from [library source](https://github.com/tronikos/androidtvremote2))
|
||||
|
||||
Source of truth read: [`androidtv_remote.py`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py),
|
||||
[`remotemessage.proto`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto),
|
||||
[`TvKeys.txt`](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
|
||||
|
||||
| Capability | API | MVP relevance |
|
||||
|---|---|---|
|
||||
| Key codes + length variants | `send_key_command(key, direction)` — `SHORT`/`START_LONG`/`END_LONG`; accepts int or `"KEYCODE_..."/"POWER"` strings; any code from the 300-strong proto enum | Remote entity port (Phase 2) matches core's SHORT/START_LONG/END_LONG design |
|
||||
| **Text / IME** | `send_text(text)`; also `send_key_command("text:<message>")` routes into send_text | Phase-2 IME option (default off). Known quirk: append-vs-replace inconsistency (#32) — document, don't ship cleverness |
|
||||
| App launch | `send_launch_app_command(app_link_or_app_id)` — **auto-prefixes `market://launch?id=`** when the value has no URL scheme (C-LAUNCH) | `play_media(url)`/`play_media(app)` (Phase 4): bare package names work with zero extra code; deep links like `plex://` pass through unchanged |
|
||||
| Push callbacks | `add_is_on_updated_callback`, `add_current_app_updated_callback`, `add_volume_info_updated_callback`, `add_is_available_updated_callback` | Exactly the manager-listener pattern Phase 1/2 needs; `current_app` push is the Phase-3 primary foreground signal |
|
||||
| Volume info (read-only) | `volume_info` → `(level, max, muted)` pushed on change | Real mute state + numeric level readout in Phase 2 — **but no `volume_set`**; see C-VOL |
|
||||
| Reconnect + reauth distinction | `keep_reconnecting(invalid_auth_callback=<fn>)` with internal exponential backoff 0.1 s → 30 s cap; raises `InvalidAuth` when certs are rejected (device hard reset) | Feeds the transport design; **one owner per transport** — don't build a competing watchdog for the protocol socket; `InvalidAuth` → reauth flow, not retry storm |
|
||||
| Device info | `device_info` (model, vendor, package_name, app_version) | Free diagnostics data for Phase 4 (and a nice setup sanity check) |
|
||||
| App-list push | **Not implemented and not in the protocol this client speaks** ([#17](https://github.com/tronikos/androidtvremote2/issues/17)). Verified against the full proto: the only app-ish messages are `RemoteAppInfo` (IME context) and `RemoteAppLinkLaunchRequest` — no app-list leafs | Confirms: options-defined app name/icon map is the honest substitute; do not promise app-list discovery |
|
||||
| IME sensitivity | Constructor `enable_ime=True` — "**Needed for getting current_app**; disable for devices that show 'Use keyboard on mobile device screen'" | If we make IME configurable, document that disabling it can break the `current_app` push (which Phase 3 depends on) |
|
||||
| Voice | `start_voice()` → `VoiceStream` (PCM 16-bit mono 8 kHz, 20 KB chunks), `enable_voice=True` | Post-MVP |
|
||||
| `RemoteSetVolumeLevel` exists in the proto but is [sent by noone](https://github.com/tronikos/androidtvremote2/issues/176505) (see C-VOL) | — | Do not plan on it for VOLUME_SET |
|
||||
|
||||
## 3. Patterns worth borrowing from other HA integrations (docs verified)
|
||||
|
||||
All four integration docs read in full ([Roku](https://www.home-assistant.io/integrations/roku/),
|
||||
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/),
|
||||
[Bravia](https://www.home-assistant.io/integrations/braviatv/),
|
||||
[androidtv](https://www.home-assistant.io/integrations/androidtv/)).
|
||||
|
||||
| Integration | Mechanism | What to borrow | Effort for us |
|
||||
|---|---|---|---|
|
||||
| **Roku** | `play_media(media_content_type: channel)` → tune by number (e.g. `5.1`); `select_source` accepts app name *or* app id; BrowseMedia for apps; channel tuning for Roku TV devices; `media_content_type: app` with `extra.content_id`/`extra.media_type` for content deeplinks; `remote` commands incl. `channel_up`/`channel_down`; active app + active app id sensors; `media_content_type: url` + `extra{format,name,thumbnail,artist_name}` for PlayOnRoku direct playback; obtains app list via a published XML endpoint (`/query/apps`) | *Channel tuning semantic* — exactly our `channel` port (validated). *App deep link envelope shape*: `media_content_type: app` with `extra.content_id`/`extra.media_type` is a richer pattern than raw URLs if we later want per-app content launches — record it but don't build it for MVP. *Source list built from apps* matches our `select_source` = launch app. *Channel up/down as remote commands* — bolt `channel_up`/`channel_down` onto Phase-2 remote command set (keycodes 166/167, free) | Low–medium |
|
||||
| **Apple TV** | `play_media(media_content_type: url)` with `youtube://...`, `https://www.netflix.com/title/...` "Deep Links" (canonical how-to + link-discovery tips via iOS share sheets); `select_source` + BrowseMedia "Apps" folder for launching apps; `keyboard` entity (binary_sensor "keyboard focused") + three text actions (`set/append/clear_keyboard_text`) driven by keyboard-focus push; remote entity with `num_repeats`/`delay_secs`/`hold_secs` | *play_media(url) deep-link semantics* for Phase 4, direct port (mediatype `url` → `send_launch_app_command`). *"Apps folder"* validation for BrowseMedia. *Keyboard-focus push → typed-text entry* is Apple TV's answer to #32's append/replace ambiguity — a keyboard entity would need an IME-push equivalent the Shield protocol does not give us cheaply, so for MVP ship `send_text` only when IME is on (nothing fancier). *Remote entity parameters* (`num_repeats`/`delay_secs`/`hold_secs`) are the shape we already planned | Medium (only if we chase keyboard); Low for the rest |
|
||||
| **Sony Bravia** | `play_media` matches channel number, **exact or partial app/channel name**, or URI string; BrowseMedia lists installed apps + TV channels; remote commands incl. `Num0..Num9` (digit-typing built into the remote platform); documented real-world limitation that TVs running Android/Google TV lose in-app playback info unless you pair with Google Cast (they recommend **Universal Media Player with two children** — exactly the problem our all-in-one design solves natively) | *Fuzzy matching on media_content_id* (number OR exact/partial name) is a small and very forgiving UX win for `channel` — accept a numeric string strictly, but also accept exact channel/app names from an options-defined map if cheap. *The Bravia doc explicitly documents the Cast+Universal-Media-Player workaround* — citable pattern that our native ladder replaces for Shield users | Low (for the simple variant) |
|
||||
| **androidtv (core, ADB/Fire TV)** | `select_source` = launch app; **`!` prefix closes app** (`!com.netflix.ninja`); options editors for *apps list, exclude unknown, arbitrary ADB turn_on/turn_off commands, per-app `state_detection_rules`*; screen-capture album art option; `adb_command`/`upload`/`download` actions; `learn_sendevent` helper | *`!` close-app prefix is a tiny, delightful, zero-new-dep feature* — implement for free alongside our Phase-4 launch path; document it as an androidtv compatibility nicety. *Arbitrary turn_on/turn_off commands as options* is worth matching (we already have shell; gives users a knob without code). *`state_detection_rules`* (per-app playing/paused conditions over `media_session_state`, `audio_state`, `wake_lock_size`) is the blueprint for our last-known cache tier to become *configurable* if users need app-specific truth — out of ADB/MVP scope but record it. *Screen-capture album art* is the precedent for the screencap idea in §4 (Roku/Bravia do not need it; we do) | Low |
|
||||
|
||||
Canonical patterns (all four):
|
||||
`select_source` = launch app with `source_list` of apps; BrowseMedia with a flat
|
||||
"Apps" directory; `play_media` channel digits or named matches; per-app
|
||||
icon/name map; documented `media_content_type` vocabulary (`channel`, `app`, `url`,
|
||||
`music`, `video`) — we should mirror this vocabulary exactly in Phase 2/4.
|
||||
|
||||
## 4. adb_shell capabilities we are not using (verified from [adb_shell source](https://github.com/JeffLIrion/adb_shell))
|
||||
|
||||
Source of truth read: [`adb_shell/adb_device.py`](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) (the full file) and [README.rst](https://raw.githubusercontent.com/JeffLIrion/adb_shell/master/README.rst).
|
||||
|
||||
| Capability | API | MVP relevance |
|
||||
|---|---|---|
|
||||
| **Streaming shell** | `AdbDevice.streaming_shell(command, decode=True)` — a *generator that yields lines as they arrive, until the connection is closed* | ⚠️ Plan correction. `MVP_PLAN.md` §2 currently says "`adb_shell`'s shell() is request→full-response; there is no incremental/streaming read API". **That is no longer accurate on master** — `streaming_shell` exists as a documented generator on `AdbDevice`. Two caveats before acting: (a) verify it is present in the resolved version we pin (repo last pushed 2024‑10; check release ≥ ours covers it — the version is old enough that the feature set may differ slightly); (b) verify the *async* device class exposing it (the source read was the sync `AdbDevice`; the async subclass typically mirrors). If both check out, MVP_PLAN's "Phase-4 optional: logcat streaming behind an option" becomes **much cheaper than hacking adb_shell internals** — and the deferred logcat snapshot approach gains a real path. Rank as the single highest-value adb_shell finding |
|
||||
| **FileSync** | `push()`, `pull(device_path, local_path, ...)`, `list(device_path)` — full FileSync protocol | Enables clean `pull`-based `screencap -p /local && pull` work flows (crisper than `shell screencap -p` stdout parsing) for album art, debugging, or future thumbnails; zero new deps |
|
||||
| Lazy shell decode | `decode=False` on shell | Micro-win for binary/inspection commands |
|
||||
| USB | `AdbDeviceUsb` (experimental, `[usb]` extra) | Not for MVP (cradle-cable setup); note for future reference only |
|
||||
| Long-lived TCP connect | `AdbDeviceTcp(host, port, default_transport_timeout_s=...)` + `connect(rsa_keys=[signer], auth_timeout_s=...)` with per-call timeouts | What Phase 1 already relies on; the per-transaction timeout fields (`transport_timeout_s`/`read_timeout_s`/`timeout_s`) are worth being explicit about in the manager's health-check design rather than default-None everywhere |
|
||||
|
||||
## 5. TiviMate specifics
|
||||
|
||||
**What TiviMate gives us: essentially nothing at the device level.**
|
||||
- Package `ar.tvplayer.tv`; no media session (R2, verified twice — before/after a
|
||||
zap produced byte-identical output) and appears only in the "Audio playback"
|
||||
section of `dumpsys media_session`.
|
||||
- [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4):
|
||||
independent confirmation that TiviMate produces nothing captureable by
|
||||
notification/session bridges on the Shield.
|
||||
- [Channel-switch automation thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344):
|
||||
the community's only working channel-select recipe is the digit-keyevent path we
|
||||
already planned (plus a ~7 s delay for app cold start).
|
||||
- **No deep link published by users** in the deep-linking guide or elsewhere for
|
||||
`ar.tvplayer.tv` — treat as "no shortcut"; if ever one surfaces, `am start` with
|
||||
extras via ADB is the Phase-4 "apps the protocol can't launch" seam.
|
||||
- **Channel list export:** TiviMate does not expose a device-side API for the
|
||||
channel list the community can use — the ecosystem gets channels from the
|
||||
upstream M3U/XMLTV sources that TiviMate loads, not from the app. Community
|
||||
projects around TiviMate (playlists, EPG ([EPGTalk](https://github.com/acidjesuz/EPGTalk)), playlist auto-updaters) take
|
||||
their data from those links. **MVP implication:** if we ever want "channel
|
||||
N = <name>" metadata, the data side belongs to the user's playlist/EPG pipeline
|
||||
(e.g. options field to store a channel list file), *not* to the integration
|
||||
scraping the TV.
|
||||
- **Channel/zap-change detection while watching:** no project has reliable on-device
|
||||
telemetry (the R2 capture shows the dump is zap-invariant). Realistic best
|
||||
signals remain the ones we have chosen: A "Audio playback" presence (identity)
|
||||
and `last_channel` cache (what *we* last asked for). The Phase-1 optional
|
||||
logcat-on-a-device process channel may eventually reveal app-internal logs, but
|
||||
no public, stable, IPTV-app-level pattern exists to borrow.
|
||||
|
||||
**Conclusion:** our current design (digits via keys, foreground fallback + audio-playback
|
||||
hint + last-known cache) is at parity with what the community does; the only
|
||||
additions worth recording are the ~7 s app-cold-start note in automation docs and the
|
||||
"channel list data belongs outside the integration" decision.
|
||||
|
||||
## 6. Ranked ideas
|
||||
|
||||
Ranking by: (a) fits locked MVP decisions, (b) cost, (c) user-verifiable payoff.
|
||||
"Phase" is where it should land in the current plan.
|
||||
|
||||
| Rank | Idea | MVP fit | Effort | Phase |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **`channel_up` / `channel_down` on the remote+media_player command sets** — free with the protocol's `KEYCODE_CHANNEL_UP`(166)/`KEYCODE_CHANNEL_DOWN`(167); Roku/Bravia precedent for making tuning feel native; zero new logic since we already send keycodes for digits | High | Trivial (hours) | 2 (fall-out) |
|
||||
| 2 | **`!` app-close prefix on `select_source`/`play_media(app)`** — androidtv precedent (`!com.netflix.ninja`); one-stop fix for "app won't die" automation scripts; zero new deps or surfaces | High | Small (half day incl. tests) | 4 |
|
||||
| 3 | **`play_media(media_content_type: url)` deep links via `send_launch_app_command`** with a cloud-less data-driven *deeplink map option* (validated against the community-wide guide; the library accepts either raw deep link or bare package id) — plus the guide's `adb shell cmd package dump` trick documented for contributors to discover links per app | High | Small–medium (~1 day incl. options editor) | 4 |
|
||||
| 4 | **BrowseMedia "Apps" directory** from running_apps + options-defined app map (androidtv_remote/Apple TV/Bravia precedent) — the single most-requested QoL pattern in this space and the reason users hit external cards ([#117126](https://github.com/home-assistant/core/pull/117126), [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48)) | High | Medium (~1–2 days incl. icons + tests) | 4 |
|
||||
| 5 | **Volume readout hardening in Phase 2** — `volume_info` push sourced real `volume_level` and mute, with `media_player.volume_set` deliberately unsupported (C-VOL correction, already adopted); if we hear repeated user demand later, offer an ADB-step-loop helper service documented as such (not fake features) | Medium (already handled) | done (plan note only) | 2 |
|
||||
| 6 | **adb_shell streaming_shell probe** — spike: confirm `streaming_shell` exists at our pinned adb-shell version and on the async class; if yes, MVP_PLAN's deferred logcat-on-ADB idea is feasible via a documented generator rather than internal hacks; if no, keeps the snapshot-only plan honest. Cheap spike, high leverage on a currently-"impossible" feature | Medium | Small (half-day spike) | 4 (optional path) |
|
||||
| 7 | **Bravia-style tolerant `media_content_id` matching** for `channel` (accept `5.1`-style floats and exact-name matches from the app/channel map) — small UX win, safe with a strict numeric path retained | Medium | Small | 3/4 |
|
||||
| 8 | **Screen-capture album art (adb_shell `screencap`/`pull`)** — androidtv precedent supplies the pattern; useful only for our PlayOnRoku-missing, Jellyfin-cached apps; keep it as a documented option like androidtv does, since it costs extra ADB shell round trips when playing and is privacy-scope-sensitive | Medium | Medium (~1 day + options slosh) | Post-MVP |
|
||||
| 9 | **IME text entry surfaced as a remote/service integration** — possible now that the library has `send_text`/`text:` syntax, but per #32 behavior is inconsistent; Apple TV solves this with a keyboard-focus push that the Shield protocol doesn't give us cheaply; keep IME an options toggle, not an entity | Low for MVP | Medium | 4 (option only) |
|
||||
| 10 | **`state_detection_rules`-style per-app configurable state ladder** (androidtv pattern) — only if/when users ask for app-specific truth beyond ADB sessions (e.g. IPTV VOD pages); nice future seam; don't build pre-emptively | Low for MVP | Medium–large | Post-MVP |
|
||||
| 11 | **Voice (`start_voice`)** — available in the library, `enable_voice=True` and device-feature-gated; not in our MVP scope, recorded so it's not re-researched | Low | Medium | Post-MVP |
|
||||
|
||||
Key references for the rankings (primary, not forum threads only):
|
||||
[core#176505](https://github.com/home-assistant/core/issues/176505) ·
|
||||
[#117126](https://github.com/home-assistant/core/pull/117126) ·
|
||||
[#17](https://github.com/tronikos/androidtvremote2/issues/17) ·
|
||||
[#32](https://github.com/tronikos/androidtvremote2/issues/32) ·
|
||||
[#28](https://github.com/tronikos/androidtvremote2/issues/28) ·
|
||||
[androidtvremote2 source](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py) ·
|
||||
[proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto) ·
|
||||
[adb_shell device](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) ·
|
||||
[Roku](https://www.home-assistant.io/integrations/roku/) ·
|
||||
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/) ·
|
||||
[Bravia](https://www.home-assistant.io/integrations/braviatv/) ·
|
||||
[androidtv](https://www.home-assistant.io/integrations/androidtv/) ·
|
||||
[deep linking guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) ·
|
||||
[community TiviMate thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) ·
|
||||
[Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48) ·
|
||||
[MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
|
||||
|
||||
---
|
||||
|
||||
## 7. Explicit MVP-plan corrections proposed (for Phase-3/4 lands)
|
||||
|
||||
1. **C-VOL (already adopted by captain).** `volume_set` is not protocol-native — the
|
||||
supported features in Phase 2 stay `VOLUME_STEP|MUTE`, with real numeric
|
||||
`volume_level` and `is_volume_muted` readouts from the protocol's `volume_info`
|
||||
push. Absolute-volume demand, if it ever recurs, is addressed honestly via an
|
||||
ADB `input keyevent KEYCODE_VOLUME_*` helper script, documented as such, not as
|
||||
a fake supported feature.
|
||||
2. **C-STREAM (new).** MVP_PLAN §2's statement that adb_shell offers no
|
||||
incremental/streaming read should be softened: `streaming_shell` exists
|
||||
upstream (at least on master) and might make the deferred Phase-4 logcat idea
|
||||
a one-day spike instead of "needs new dependency". Verify our pinned version
|
||||
carries it and whether the async device class exposes it, then either upgrade
|
||||
to a "cheap Phase-4 item" or keep the deferred note with accurate wording.
|
||||
3. **C-LAUNCH (new, informational).** `send_launch_app_command` already accepts a bare
|
||||
package id by auto-prefixing `market://launch?id=`; Phase 4 needs no extra
|
||||
branch for "app" launches, only for deep links (where the value already has a
|
||||
scheme) and `!`-close via ADB (only when the protocol path is not enough).
|
||||
4. **C-TIVI (new).** TiviMate has no usable on-device channel metadata or export
|
||||
surface; the honest MVP display is "on, in TiviMate" + `last_channel` cache
|
||||
from our own play_media calls. Channel-name metadata is a user-supplied
|
||||
playlist/EPG concern, out of scope (and out of ADB's reach).
|
||||
@@ -394,3 +394,30 @@ media_player:
|
||||
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
|
||||
Phase 3 lands (documented in README).
|
||||
10. Netflix stale-session fixture pins "inactive no-state sessions ignored".
|
||||
|
||||
---
|
||||
|
||||
## Appendix — mediasession2mqtt retirement + universal-player glue (Phase 3)
|
||||
|
||||
The fork retires the surrounding glue once the state ladder (README, Phase 3)
|
||||
covers what the external pieces provided:
|
||||
|
||||
| External piece | What it provided | Now native (file) |
|
||||
|---|---|---|
|
||||
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
|
||||
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
|
||||
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
|
||||
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
|
||||
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
|
||||
|
||||
The migration path: point the automations/scripts at the integration's DAG
|
||||
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
|
||||
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
|
||||
needed for the MVP feature set; the appendix exists so retirement can be
|
||||
staged per household.
|
||||
|
||||
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
|
||||
publishes no MediaSession on Shield Android 11. Its visibility comes from the
|
||||
foreground tier (tier 3.5) with the audio-playback hint and the
|
||||
last-known-state cache — never from a session, never resurrected past the
|
||||
state TTL.
|
||||
+4
-7
@@ -14,14 +14,11 @@ dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
|
||||
# for HACS installs; used editable from a sibling checkout for development.
|
||||
# Planned migration to a proper PyPI package.
|
||||
"ha-adb-common",
|
||||
"androidtvremote2>=0.2.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
ha-adb-common = { path = "../ha-adb-common", editable = true }
|
||||
# The shared ADB helpers are vendored at
|
||||
# custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
|
||||
# this fork, so no extra dev dependency (and no unavailable sibling checkout).
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Test bootstrap.
|
||||
|
||||
Puts the vendored ha_adb_common package on sys.path so helpers can be imported
|
||||
directly (their code is stdlib-only) without installing Home Assistant or
|
||||
executing the integration's __init__ module.
|
||||
|
||||
The integration-package modules that stay Home-Assistant-free at import time
|
||||
(currently connection.py) are also made importable — via a synthetic package
|
||||
("nsh") that wraps custom_components/nvidia_shield WITHOUT executing its
|
||||
__init__.py (that one imports Home Assistant). Only the stdlib-only modules
|
||||
are loaded into it explicitly; their relative imports resolve normally.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "custom_components" / "nvidia_shield"))
|
||||
|
||||
_SHIELD_DIR = REPO_ROOT / "custom_components" / "nvidia_shield"
|
||||
_PKG = "nsh"
|
||||
|
||||
|
||||
def _load_module(name: str, path: Path, search_paths: list[str] | None = None):
|
||||
"""Import a source file under ``name`` without touching real packages."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, path, submodule_search_locations=search_paths
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# Synthetic integration package — no __init__.py execution (HA-free).
|
||||
_root = types.ModuleType(_PKG)
|
||||
_root.__path__ = [str(_SHIELD_DIR)]
|
||||
sys.modules[_PKG] = _root
|
||||
|
||||
# Real subpackage __init__ (stdlib-only) so `from .ha_adb_common import ...`
|
||||
# works through the synthetic package.
|
||||
_load_module(
|
||||
f"{_PKG}.ha_adb_common",
|
||||
_SHIELD_DIR / "ha_adb_common" / "__init__.py",
|
||||
[str(_SHIELD_DIR / "ha_adb_common")],
|
||||
)
|
||||
|
||||
# Stdlib-only integration modules.
|
||||
for _mod in ("const", "adb_session", "connection"):
|
||||
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
|
||||
|
||||
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
|
||||
enum values the integration actually uses, and the event helpers. Tests load
|
||||
their entity module through the synthetic ``nsh`` package AFTER calling
|
||||
:func:`install` (and the per-file fixture pops everything at teardown).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import sys
|
||||
import types
|
||||
from collections.abc import Mapping
|
||||
|
||||
import voluptuous_mirror
|
||||
|
||||
ENTITIES_STUB_NAMES = (
|
||||
"voluptuous",
|
||||
"homeassistant",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.exceptions",
|
||||
"homeassistant.core",
|
||||
"homeassistant.const",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.media_player",
|
||||
"homeassistant.components.remote",
|
||||
"homeassistant.components.button",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.redact",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.entity_platform",
|
||||
"nsh.helpers",
|
||||
"nsh.power",
|
||||
"nsh.jellyfin",
|
||||
"nsh.media_player",
|
||||
"nsh.diagnostics",
|
||||
"nsh.remote",
|
||||
)
|
||||
|
||||
|
||||
class EntitySelectorStub:
|
||||
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
||||
|
||||
Real HA answers a cleared entity field with "Schema validation failed at …"
|
||||
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
|
||||
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
|
||||
values to prove it.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
raise ValueError("Entity is not a valid entity id")
|
||||
return data
|
||||
|
||||
|
||||
def install_selector_faces(selector_module) -> None:
|
||||
"""Populate a ``homeassistant.helpers.selector`` stub module.
|
||||
|
||||
One definition for every installer (entity_stublib and the four per-file
|
||||
installers in the flow tests), so the stubs cannot drift apart.
|
||||
"""
|
||||
selector_module.EntitySelector = EntitySelectorStub
|
||||
|
||||
class _Config(types.SimpleNamespace):
|
||||
"""Selector configs keep their kwargs (real HA stores them as fields).
|
||||
|
||||
The stub needs them inspectable: which domains a selector offers is a
|
||||
user-visible contract (t56 — the Receiver field accepts a switch), and a
|
||||
lambda throwing the value away cannot pin it.
|
||||
"""
|
||||
|
||||
selector_module.EntitySelectorConfig = _Config
|
||||
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||
selector_module.SelectSelectorConfig = _Config
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
|
||||
|
||||
class MediaPlayerState(enum.StrEnum):
|
||||
OFF = "off"
|
||||
ON = "on"
|
||||
IDLE = "idle"
|
||||
PLAYING = "playing"
|
||||
PAUSED = "paused"
|
||||
STANDBY = "standby"
|
||||
BUFFERING = "buffering"
|
||||
|
||||
|
||||
class MediaPlayerEntityFeature(enum.IntFlag):
|
||||
PLAY = 1
|
||||
PAUSE = 2
|
||||
STOP = 4
|
||||
VOLUME_MUTE = 8
|
||||
VOLUME_STEP = 16
|
||||
VOLUME_SET = 32
|
||||
BROWSE_MEDIA = 4096
|
||||
TURN_ON = 64
|
||||
TURN_OFF = 128
|
||||
PLAY_MEDIA = 256
|
||||
NEXT_TRACK = 512
|
||||
PREVIOUS_TRACK = 1024
|
||||
SELECT_SOURCE = 2048
|
||||
|
||||
|
||||
class MediaClass:
|
||||
"""homeassistant.components.media_player.MediaClass (values we use)."""
|
||||
|
||||
APP = "app"
|
||||
DIRECTORY = "directory"
|
||||
|
||||
|
||||
class MediaType:
|
||||
"""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__(
|
||||
self,
|
||||
*,
|
||||
media_class,
|
||||
media_content_id,
|
||||
media_content_type,
|
||||
title,
|
||||
can_play,
|
||||
can_expand,
|
||||
children=None,
|
||||
children_media_class=None,
|
||||
thumbnail=None,
|
||||
not_shown=0,
|
||||
can_search=False,
|
||||
search_media_classes=None,
|
||||
):
|
||||
self.media_class = media_class
|
||||
self.media_content_id = media_content_id
|
||||
self.media_content_type = media_content_type
|
||||
self.title = title
|
||||
self.can_play = can_play
|
||||
self.can_expand = can_expand
|
||||
self.children = children
|
||||
self.children_media_class = children_media_class
|
||||
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:
|
||||
"""trivial stand-in for the HA platform internals we don't exercise."""
|
||||
|
||||
|
||||
class MediaPlayerEntity:
|
||||
"""Base entity: attributes only; state writing is a no-op."""
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class RemoteEntity:
|
||||
def async_write_ha_state(self) -> 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:
|
||||
"""Register the stub modules (idempotent, and self-healing).
|
||||
|
||||
Callers must be able to rely on this regardless of what a previous test file
|
||||
popped: a missing face rebuilds the whole set, so any subset or file order
|
||||
works. A complete set is left alone — a file that installed richer faces of
|
||||
its own (tests/test_config_flow_ux.py's voluptuous mirror, the discovery
|
||||
stub) keeps them.
|
||||
"""
|
||||
if all(name in sys.modules for name in _STUB_FACES):
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
|
||||
# stub cannot tell a schema WITH a default from one without.
|
||||
voluptuous_mirror.install(vol)
|
||||
sys.modules["voluptuous"] = vol
|
||||
|
||||
ha = types.ModuleType("homeassistant")
|
||||
sys.modules["homeassistant"] = ha
|
||||
|
||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||
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(
|
||||
"ConfigFlow",
|
||||
(_ConfigFlowStub,),
|
||||
{
|
||||
"__init_subclass__": classmethod(
|
||||
lambda cls, domain=None, **kwargs: None
|
||||
),
|
||||
"__init__": _config_flow_init,
|
||||
},
|
||||
)
|
||||
config_entries.ConfigFlow = _config_flow_base
|
||||
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
|
||||
|
||||
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.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
core.CALLBACK_TYPE = object
|
||||
core.Event = object
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
ha_const = types.ModuleType("homeassistant.const")
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.STATE_PLAYING = "playing"
|
||||
ha_const.STATE_PAUSED = "paused"
|
||||
# t61: a receiver switch is read and switched by the media player, so the
|
||||
# two switch states have to exist with their real values.
|
||||
ha_const.STATE_ON = "on"
|
||||
ha_const.STATE_OFF = "off"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
REMOTE="remote",
|
||||
BUTTON="button",
|
||||
)
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
components = types.ModuleType("homeassistant.components")
|
||||
components.__path__ = []
|
||||
sys.modules["homeassistant.components"] = components
|
||||
|
||||
mp = types.ModuleType("homeassistant.components.media_player")
|
||||
mp.MediaPlayerEntity = MediaPlayerEntity
|
||||
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
||||
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
||||
mp.BrowseMedia = BrowseMedia
|
||||
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
|
||||
sys.modules["homeassistant.components.media_player"] = mp
|
||||
|
||||
remote = types.ModuleType("homeassistant.components.remote")
|
||||
remote.RemoteEntity = RemoteEntity
|
||||
sys.modules["homeassistant.components.remote"] = remote
|
||||
|
||||
button = types.ModuleType("homeassistant.components.button")
|
||||
button.ButtonEntity = type("ButtonEntity", (), {})
|
||||
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")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
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
|
||||
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.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||
helpers.event = event
|
||||
helpers.selector = types.SimpleNamespace(
|
||||
EntitySelector=EntitySelectorStub,
|
||||
EntitySelectorConfig=lambda *a, **k: "",
|
||||
SelectSelector=lambda *a, **k: None,
|
||||
SelectSelectorConfig=lambda **k: "",
|
||||
SelectSelectorMode=types.SimpleNamespace(DROPDOWN="dropdown"),
|
||||
)
|
||||
entity = types.ModuleType("homeassistant.helpers.entity")
|
||||
entity.DeviceInfo = dict
|
||||
entity.EntityCategory = types.SimpleNamespace(
|
||||
CONFIG="config", DIAGNOSTIC="diagnostic"
|
||||
)
|
||||
helpers.entity = entity
|
||||
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||
entity_platform.AddEntitiesCallback = object
|
||||
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.entity"] = entity
|
||||
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||
|
||||
# t44: config_flow imports `selector` from this package, and the per-file stub
|
||||
# installers register it as its own face. Installing it here too keeps
|
||||
# `_STUB_FACES` honest: install() then really does provide every face a test
|
||||
# file may have popped, which is what its guard checks.
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
install_selector_faces(selector)
|
||||
selector.SelectSelector = lambda *args, **kwargs: None
|
||||
selector.SelectSelectorConfig = lambda **kwargs: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
helpers.selector = selector
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
sys.modules["homeassistant.helpers"] = helpers
|
||||
|
||||
|
||||
#: Namespaces the stub harness owns. Anything under these prefixes that a test
|
||||
#: file creates is harness state, so a file's teardown must remove it: a module
|
||||
#: left behind keeps the stub objects it captured (an exception class, the redact
|
||||
#: helper the diagnostics module holds) after the next file rebuilt them, which is
|
||||
#: how a file-order-dependent failure looked (t44).
|
||||
_TRACKED_PREFIXES = ("homeassistant", "voluptuous", "nsh")
|
||||
|
||||
|
||||
def _tracked(name: str) -> bool:
|
||||
return name.split(".", 1)[0] in _TRACKED_PREFIXES
|
||||
|
||||
|
||||
def snapshot_modules() -> dict:
|
||||
"""Every stub/module name live right now — for a leak-free teardown."""
|
||||
return {name: module for name, module in sys.modules.items() if _tracked(name)}
|
||||
|
||||
|
||||
def restore_modules(snapshot: dict) -> None:
|
||||
"""Undo everything the current test file did to those namespaces.
|
||||
|
||||
Two halves, and the first is what a hand-written list of names kept getting
|
||||
wrong: drop every tracked name the file created (so the next file loads it
|
||||
fresh, against ITS stubs), then put back every object it replaced.
|
||||
"""
|
||||
for name in [name for name in sys.modules if _tracked(name) and name not in snapshot]:
|
||||
del sys.modules[name]
|
||||
sys.modules.update(snapshot)
|
||||
|
||||
|
||||
def load_entity_modules() -> dict:
|
||||
"""Load the integration's HA-free-ish entity modules under nsh."""
|
||||
install()
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
|
||||
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:
|
||||
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
||||
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
||||
return {
|
||||
name: sys.modules[f"nsh.{name}"]
|
||||
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:
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later.
|
||||
|
||||
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
||||
code that reads a linked entity works unmodified; it is empty until a test
|
||||
sets something.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.states = FakeStates()
|
||||
|
||||
def async_create_task(self, coro):
|
||||
return asyncio.create_task(coro)
|
||||
|
||||
def async_call_later(self, delay, callback_fn):
|
||||
return lambda: None
|
||||
@@ -0,0 +1,5 @@
|
||||
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||
state=RESUMED stopped=false delayedResume=false finishing=false
|
||||
Resumed activities in task display areas (from top to bottom):
|
||||
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
SYNTHETIC fixture — not a real device capture. The real Shield `dumpsys
|
||||
activity recents` capture (R6) is still outstanding; this file pins the
|
||||
parsing contract of AdbClientBase.poll_running_apps in the meantime, covering
|
||||
both the A=uid:package and the I=package/activity fallback patterns.
|
||||
Represents the post-grep output of:
|
||||
dumpsys activity recents 2>/dev/null | grep -E 'Recent #|realActivity|visible='
|
||||
Note: line order = recency order (most recently used first).
|
||||
* Recent #0: Task{2c9f81f #817 type=standard A=10185:ar.tvplayer.tv I=ar.tvplayer.tv/.ui.MainActivity ... visible=true numActivities=1 ...}
|
||||
* Recent #1: Task{3dae20b #802 type=standard A=10106:com.github.damontecres.wholphin ... visible=false numActivities=1 ...}
|
||||
* Recent #2: Task{4b7ce01 #788 type=standard I=com.example.launcherapp/.ui.HomeActivity ... visible=true ...}
|
||||
* Recent #3: Task{5c8dd12 #770 type=standard A=1000:com.android.systemui ... visible=true ...}
|
||||
* Recent #4: Task{6d9ef23 #750 type=unknown A=10003:.HiddenInternalTask ... visible=true ...}
|
||||
@@ -0,0 +1,2 @@
|
||||
mWakefulness=Asleep
|
||||
mWakefulnessChanging=false
|
||||
@@ -0,0 +1,2 @@
|
||||
mWakefulness=Awake
|
||||
mWakefulnessChanging=false
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
MEDIA SESSION SERVICE (dumpsys media_session)
|
||||
|
||||
9 sessions listeners.
|
||||
Global priority session is null
|
||||
User Records:
|
||||
Record for full_user=0
|
||||
Volume key long-press listener: null
|
||||
Volume key long-press listener package:
|
||||
Media key listener: null
|
||||
Media key listener package:
|
||||
OnMediaKeyEventDispatchedListener: added 0 listener(s)
|
||||
OnMediaKeyEventSessionChangedListener: added 1 listener(s)
|
||||
from com.android.bluetooth
|
||||
Last MediaButtonReceiver: null
|
||||
Media button session is null
|
||||
Sessions Stack - have 1 sessions:
|
||||
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
|
||||
ownerPid=5833, ownerUid=10080, userId=0
|
||||
package=com.netflix.ninja
|
||||
launchIntent=null
|
||||
mediaButtonReceiver=null
|
||||
active=false
|
||||
flags=3
|
||||
rating type=0
|
||||
controllers: 0
|
||||
state=null
|
||||
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
|
||||
volumeType=1, controlType=2, max=0, current=0
|
||||
metadata: null
|
||||
queueTitle=null, size=0
|
||||
Audio playback (lastly played comes first)
|
||||
uid=10117 packages=ar.tvplayer.tv
|
||||
uid=10006 packages=com.google.android.apps.mediashell
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
MEDIA SESSION SERVICE (dumpsys media_session)
|
||||
|
||||
9 sessions listeners.
|
||||
User Records:
|
||||
Record for full_user=0
|
||||
Media button session is null
|
||||
Sessions Stack - have 1 sessions:
|
||||
Wholphin media session com.github.damontecres.wholphin/Wholphin media session (userId=0)
|
||||
ownerPid=6117, ownerUid=10117, userId=0
|
||||
package=com.github.damontecres.wholphin
|
||||
launchIntent=null
|
||||
mediaButtonReceiver=null
|
||||
active=true
|
||||
flags=3
|
||||
rating type=0
|
||||
controllers: 1
|
||||
state=PlaybackState {state=3, position=17237, buffered position=70070, speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item id=0, error=null}
|
||||
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
|
||||
volumeType=1, controlType=2, max=0, current=0
|
||||
metadata: size=11, description=Twirlywoos, null, null
|
||||
queueTitle=null, size=1
|
||||
Audio playback (lastly played comes first)
|
||||
uid=10117 packages=com.github.damontecres.wholphin
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
"""Tests for AdbClientBase additions: exact wakefulness parse (R5),
|
||||
health_check(), and optional media-session truncation (plan blocker B4).
|
||||
|
||||
The client runs against a FakeDevice recording shell commands — no real ADB
|
||||
socket or adb_shell import is needed.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ha_adb_common.adb_client import AdbClientBase, parse_wakefulness
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
CMD_WAKEFULNESS = "dumpsys power 2>/dev/null | grep mWakefulness"
|
||||
CMD_MEDIA_DEFAULT = "dumpsys media_session 2>/dev/null | head -200"
|
||||
CMD_MEDIA_FULL = "dumpsys media_session 2>/dev/null"
|
||||
CMD_ECHO_OK = "echo ok"
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
"""Records shell commands and returns canned responses per command."""
|
||||
|
||||
def __init__(self, responses=None):
|
||||
self._responses = dict(responses or {})
|
||||
self.commands: list[str] = []
|
||||
|
||||
async def shell(self, cmd: str) -> str | None:
|
||||
self.commands.append(cmd)
|
||||
response = self._responses.get(cmd)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
|
||||
|
||||
def _client_response(cmd: str, response: str | None) -> AdbClientBase:
|
||||
"""A client whose (fake) device is already connected and answers cmd."""
|
||||
client = AdbClientBase("shield.local", 5555, "/dev/null/adbkey")
|
||||
client._device = FakeDevice({cmd: response})
|
||||
client._connected = True # skip connect(): no adb_shell in the test env
|
||||
return client
|
||||
|
||||
|
||||
class TestParseWakefulness:
|
||||
"""Exact-value mWakefulness= parse; mWakefulnessChanging excluded."""
|
||||
|
||||
def test_awake_fixture(self):
|
||||
text = (FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text()
|
||||
assert parse_wakefulness(text) is True
|
||||
|
||||
def test_asleep_fixture(self):
|
||||
text = (FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text()
|
||||
assert parse_wakefulness(text) is False
|
||||
|
||||
def test_known_asleep_values(self):
|
||||
assert parse_wakefulness("mWakefulness=Asleep") is False
|
||||
assert parse_wakefulness("mWakefulness=Dozing") is False
|
||||
assert parse_wakefulness("mWakefulness=Dozing-suspend") is False
|
||||
|
||||
def test_unknown_value_is_unknown_not_awake(self):
|
||||
"""The old substring heuristic read any future value containing no
|
||||
'Awake' as asleep; unrecognized values must be None (captain's note)."""
|
||||
assert parse_wakefulness("mWakefulness=Dozing-ext") is None
|
||||
assert parse_wakefulness("mWakefulness=123") is None
|
||||
|
||||
def test_changing_and_setting_lines_are_ignored(self):
|
||||
assert (
|
||||
parse_wakefulness(" mWakefulnessChanging=false") is None
|
||||
)
|
||||
assert parse_wakefulness(" mWakefulnessSetting=2") is None
|
||||
|
||||
def test_assignment_wins_over_changing_line(self):
|
||||
text = " mWakefulnessChanging=false\nmWakefulness=Awake\n"
|
||||
assert parse_wakefulness(text) is True
|
||||
|
||||
def test_empty_input(self):
|
||||
assert parse_wakefulness("") is None
|
||||
assert parse_wakefulness(None) is None
|
||||
assert parse_wakefulness("no match here") is None
|
||||
|
||||
|
||||
class TestIsAwake:
|
||||
async def test_awake(self):
|
||||
client = _client_response(
|
||||
CMD_WAKEFULNESS,
|
||||
(FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text(),
|
||||
)
|
||||
assert await client.is_awake() is True
|
||||
assert client._device.commands == [CMD_WAKEFULNESS]
|
||||
|
||||
async def test_asleep(self):
|
||||
client = _client_response(
|
||||
CMD_WAKEFULNESS,
|
||||
(FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text(),
|
||||
)
|
||||
assert await client.is_awake() is False
|
||||
|
||||
async def test_unknown_reports_none(self):
|
||||
"""A garbage/empty grep result parses to None, not False."""
|
||||
client = _client_response(CMD_WAKEFULNESS, "")
|
||||
assert await client.is_awake() is None
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
async def test_echo_ok_round_trip_is_healthy(self):
|
||||
client = _client_response(CMD_ECHO_OK, "ok\n")
|
||||
assert await client.health_check() is True
|
||||
|
||||
async def test_failed_shell_is_unhealthy(self):
|
||||
client = _client_response(CMD_ECHO_OK, Exception("socket closed"))
|
||||
assert await client.health_check() is False
|
||||
assert client.connected is False # shell() marked the socket broken
|
||||
|
||||
async def test_wrong_body_is_unhealthy(self):
|
||||
client = _client_response(CMD_ECHO_OK, "something else\n")
|
||||
assert await client.health_check() is False
|
||||
|
||||
|
||||
class TestPollMediaSessionsTruncation:
|
||||
async def test_default_truncates_at_200(self):
|
||||
client = _client_response(CMD_MEDIA_DEFAULT, "")
|
||||
assert await client.poll_media_sessions() is None
|
||||
assert client._device.commands == [CMD_MEDIA_DEFAULT]
|
||||
|
||||
async def test_explicit_head_lines(self):
|
||||
client = _client_response("dumpsys media_session 2>/dev/null | head -80", "")
|
||||
await client.poll_media_sessions(head_lines=80)
|
||||
assert client._device.commands == [
|
||||
"dumpsys media_session 2>/dev/null | head -80"
|
||||
]
|
||||
|
||||
async def test_none_head_lines_fetches_full_dump(self):
|
||||
"""None = no truncation — the manager later passes None (B4)."""
|
||||
wholphin = (FIXTURES / "media_session_wholphin.txt").read_text()
|
||||
client = _client_response(CMD_MEDIA_FULL, wholphin)
|
||||
info = await client.poll_media_sessions(head_lines=None)
|
||||
assert client._device.commands == [CMD_MEDIA_FULL]
|
||||
assert info is not None
|
||||
assert info.package == "com.github.damontecres.wholphin"
|
||||
assert info.position == 17237
|
||||
assert client.all_sessions[0].playing is True
|
||||
|
||||
async def test_failed_shell_returns_none_without_reparsing(self):
|
||||
client = _client_response(CMD_MEDIA_DEFAULT, Exception("dead"))
|
||||
assert await client.poll_media_sessions() is None
|
||||
assert client.last_info is None
|
||||
|
||||
|
||||
class TestPollRunningApps:
|
||||
"""Recent-task parsing against the SYNTHETIC recents fixture.
|
||||
|
||||
The real Shield `dumpsys activity recents` capture (R6) is still
|
||||
outstanding; tests/fixtures/activity_recents_synthetic.txt pins the
|
||||
parsing contract in the meantime.
|
||||
"""
|
||||
|
||||
async def test_parses_visible_user_apps_in_recency_order(self):
|
||||
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||
client = _client_response(
|
||||
"dumpsys activity recents 2>/dev/null "
|
||||
"| grep -E 'Recent #|realActivity|visible='",
|
||||
recents,
|
||||
)
|
||||
apps = await client.poll_running_apps()
|
||||
assert [a["package"] for a in apps] == [
|
||||
"ar.tvplayer.tv",
|
||||
"com.example.launcherapp",
|
||||
]
|
||||
|
||||
async def test_i_pattern_fallback_used_without_a_uid_assignment(self):
|
||||
"""`I=com.example.launcherapp/.ui.HomeActivity` is the only package
|
||||
fragment on its line and must be picked up by the fallback regex."""
|
||||
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||
client = _client_response(
|
||||
"dumpsys activity recents 2>/dev/null "
|
||||
"| grep -E 'Recent #|realActivity|visible='",
|
||||
recents,
|
||||
)
|
||||
apps = await client.poll_running_apps()
|
||||
assert "com.example.launcherapp" in {a["package"] for a in apps}
|
||||
|
||||
async def test_skips_invisible_tasks_system_packages_and_dot_names(self):
|
||||
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
|
||||
client = _client_response(
|
||||
"dumpsys activity recents 2>/dev/null "
|
||||
"| grep -E 'Recent #|realActivity|visible='",
|
||||
recents,
|
||||
)
|
||||
apps = await client.poll_running_apps()
|
||||
packages = {a["package"] for a in apps}
|
||||
assert "com.github.damontecres.wholphin" not in packages # visible=false
|
||||
assert "com.android.systemui" not in packages # excluded system UI
|
||||
assert ".HiddenInternalTask" not in packages # dot-named internals
|
||||
|
||||
async def test_empty_result_returns_empty_list(self):
|
||||
client = _client_response(
|
||||
"dumpsys activity recents 2>/dev/null "
|
||||
"| grep -E 'Recent #|realActivity|visible='",
|
||||
None,
|
||||
)
|
||||
assert await client.poll_running_apps() == []
|
||||
|
||||
async def test_failed_shell_returns_empty_list(self):
|
||||
client = _client_response(
|
||||
"dumpsys activity recents 2>/dev/null "
|
||||
"| grep -E 'Recent #|realActivity|visible='",
|
||||
Exception("offline"),
|
||||
)
|
||||
assert await client.poll_running_apps() == []
|
||||
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"]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""F1 regression: the adb_pair submit path calls
|
||||
ShieldConnectionManager.test_connect with its declared signature
|
||||
(host, port, key_path) — nothing extra, arity exact.
|
||||
|
||||
config_flow.py imports Home Assistant; it is exercised here against small
|
||||
module stubs (import-only; no HA behaviour needed on this path) with the
|
||||
actual ADB handshake mocked on the manager.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
)
|
||||
|
||||
_KEY_PATH = "/tmp/nsh-test-adbkey"
|
||||
|
||||
|
||||
_STUB_NAMES = (
|
||||
"voluptuous",
|
||||
"homeassistant",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.core",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
# Loaded execution runs beneath the stubs; never escape this file either.
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
)
|
||||
|
||||
|
||||
def _install_import_stubs() -> None:
|
||||
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
|
||||
if sys.modules.get("voluptuous") is not None:
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
vol.Required = lambda key, default=None: key
|
||||
vol.Optional = lambda key, default=None: key
|
||||
vol.Schema = lambda schema: schema
|
||||
sys.modules["voluptuous"] = vol
|
||||
|
||||
ha = types.ModuleType("homeassistant")
|
||||
sys.modules["homeassistant"] = ha
|
||||
|
||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||
|
||||
class ConfigFlow:
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape; the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
|
||||
def _form(step_id, errors):
|
||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
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
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
helpers.entity_registry = entity_registry
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||
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")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
class _Config:
|
||||
config_dir = "/tmp/nsh-test-config"
|
||||
|
||||
|
||||
class _FakeHass:
|
||||
config = _Config
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def async_add_executor_job(fn, *args):
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _config_flow_module():
|
||||
_install_import_stubs()
|
||||
if "nsh.helpers" not in sys.modules:
|
||||
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
||||
if "nsh.config_flow" not in sys.modules:
|
||||
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
||||
return sys.modules["nsh.config_flow"]
|
||||
|
||||
|
||||
def _flow(user_input_host: str, monkeypatch=None):
|
||||
module = _config_flow_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = _FakeHass
|
||||
flow._user_data = {
|
||||
"name": "Nvidia Shield",
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_HOST: user_input_host,
|
||||
CONF_ADB_PORT: 5555,
|
||||
CONF_JELLYFIN_ENTITY: "",
|
||||
CONF_RECEIVER_ENTITY: "",
|
||||
CONF_RECEIVER_INPUT: "",
|
||||
CONF_POWER_ENTITY: "",
|
||||
CONF_ADB_STORE_APP_INFO: True,
|
||||
}
|
||||
if monkeypatch is not None:
|
||||
# Patch module-level resolution — the call site is a plain global.
|
||||
monkeypatch.setattr(
|
||||
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
|
||||
)
|
||||
return flow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_stubs():
|
||||
"""Contain the import stubs to this module's tests (t7 INFO).
|
||||
|
||||
Snapshots every injected sys.modules entry and restores or removes it on
|
||||
teardown, so later tests never observe the fake homeassistant/voluptuous
|
||||
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
|
||||
"""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_install_import_stubs()
|
||||
yield
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_test_connect(monkeypatch):
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
|
||||
mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
|
||||
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
|
||||
# (no key material — and no adb_shell keygen — during tests).
|
||||
module = _config_flow_module()
|
||||
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
|
||||
return mock
|
||||
|
||||
|
||||
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
|
||||
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
|
||||
flow = _flow("shield.local")
|
||||
|
||||
result = await flow.async_step_adb_pair({"submit": True})
|
||||
|
||||
mocked_test_connect.assert_awaited_once_with(
|
||||
"shield.local", 5555, _KEY_PATH
|
||||
)
|
||||
# t56: the pairing hands over to the setup menu; Finish creates the entry
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
|
||||
|
||||
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
|
||||
flow = _flow("shield.local")
|
||||
mocked_test_connect.return_value = False
|
||||
|
||||
result = await flow.async_step_adb_pair({"submit": True})
|
||||
|
||||
mocked_test_connect.assert_awaited_once()
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "adb_failed"}
|
||||
|
||||
|
||||
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
|
||||
flow = _flow("") # no host resolvable
|
||||
|
||||
result = await flow.async_step_adb_pair({"submit": True})
|
||||
|
||||
mocked_test_connect.assert_not_awaited()
|
||||
assert result["errors"] == {"base": "adb_no_host"}
|
||||
@@ -0,0 +1,514 @@
|
||||
"""Phase 2 step 2 tests: protocol cert/PIN pairing, reauth, entry migration.
|
||||
|
||||
Self-contained scoped-stub variant of the config-flow test harness (same
|
||||
pattern as test_config_flow_pairing.py, with pair-hygiene capabilities added:
|
||||
FakeHass records unique_id sets, entry updates and reload scheduling). The
|
||||
androidtvremote2 client is faked with a deterministic pairing code and MAC.
|
||||
"""
|
||||
|
||||
import asyncio # noqa: F401 (async steps need the event loop)
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
DEFAULT_ADB_PORT,
|
||||
)
|
||||
|
||||
_STUB_NAMES = (
|
||||
"voluptuous",
|
||||
"homeassistant",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.core",
|
||||
"homeassistant.const",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
"nsh.entry",
|
||||
)
|
||||
|
||||
FAKE_PAIRING_CODE = "8421"
|
||||
FAKE_DEVICE_NAME = "mdarcy"
|
||||
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
|
||||
class FakeInvalidAuthError(Exception):
|
||||
"""androidtvremote2.exceptions.InvalidAuth stand-in."""
|
||||
|
||||
|
||||
class FakeProtocolClient:
|
||||
"""Proto client used by the flow pairing path (deterministic)."""
|
||||
|
||||
def __init__(self, fail_code=False):
|
||||
self.cert_generated = False
|
||||
self.pairing_started = False
|
||||
self.finished_codes: list[str] = []
|
||||
self.disconnected = 0
|
||||
self._fail_code = fail_code
|
||||
|
||||
async def async_generate_cert_if_missing(self):
|
||||
self.cert_generated = True
|
||||
return True
|
||||
|
||||
async def async_start_pairing(self):
|
||||
self.pairing_started = True
|
||||
return FAKE_PAIRING_CODE # deterministic "device-side PIN"
|
||||
|
||||
async def async_finish_pairing(self, pairing_code: str) -> None:
|
||||
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
|
||||
raise FakeInvalidAuthError("device rejected the pairing code")
|
||||
self.finished_codes.append(pairing_code)
|
||||
|
||||
async def async_get_name_and_mac(self):
|
||||
return FAKE_DEVICE_NAME, FAKE_MAC
|
||||
|
||||
def disconnect(self):
|
||||
self.disconnected += 1
|
||||
|
||||
|
||||
def _install_stubs() -> None:
|
||||
"""Import-time stand-ins (idempotent, scoped by the autouse fixture)."""
|
||||
if sys.modules.get("voluptuous") is not None:
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
vol.Required = lambda key, default=None: key
|
||||
vol.Optional = lambda key, default=None: key
|
||||
vol.Schema = lambda schema: schema
|
||||
sys.modules["voluptuous"] = vol
|
||||
|
||||
ha = types.ModuleType("homeassistant")
|
||||
sys.modules["homeassistant"] = ha
|
||||
|
||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||
|
||||
class ConfigFlow:
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
kwargs.pop("domain", None)
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
|
||||
description_placeholders=None):
|
||||
# t56: the real MENU result shape; the flow manager dispatches the
|
||||
# chosen `next_step_id` to `async_step_<choice>(None)`.
|
||||
return {
|
||||
"type": "menu",
|
||||
"step_id": step_id,
|
||||
"menu_options": list(menu_options),
|
||||
"description_placeholders": dict(
|
||||
description_placeholders or {}
|
||||
),
|
||||
}
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
|
||||
def async_abort(self, *, reason):
|
||||
return {"type": "abort", "reason": reason}
|
||||
|
||||
async def async_set_unique_id(self, unique_id):
|
||||
self._unique_id = unique_id
|
||||
|
||||
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
||||
if getattr(self, "_unique_id", None) in active_unique_ids:
|
||||
raise AbortIfConfiguredError()
|
||||
|
||||
def _form(step_id, errors):
|
||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||
|
||||
class AbortIfConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
active_unique_ids = set()
|
||||
|
||||
class OptionsFlow:
|
||||
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.OptionsFlow = OptionsFlow
|
||||
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||
sys.modules["homeassistant.config_entries"] = config_entries
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
ha_const = types.ModuleType("homeassistant.const")
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
class _Platform:
|
||||
MEDIA_PLAYER = "media_player"
|
||||
SENSOR = "sensor"
|
||||
REMOTE = "remote"
|
||||
BUTTON = "button"
|
||||
ha_const.Platform = _Platform
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
helpers.entity_registry = entity_registry
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||
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")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
class FakeConfigEntries:
|
||||
def __init__(self):
|
||||
self.updating_entry_calls: list = []
|
||||
self.reloaded_entry_ids: list[str] = []
|
||||
|
||||
def async_get_entry(self, entry_id):
|
||||
return getattr(self, f"__entry_{entry_id}", None)
|
||||
|
||||
def async_update_entry(self, entry, **kwargs):
|
||||
self.updating_entry_calls.append((entry, kwargs))
|
||||
for key, value in kwargs.items():
|
||||
if key in ("options", "data"):
|
||||
setattr(entry, key, value)
|
||||
else:
|
||||
setattr(entry, key, value)
|
||||
|
||||
def async_schedule_reload(self, entry_id):
|
||||
self.reloaded_entry_ids.append(entry_id)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
def __init__(self, tmp_dir: str):
|
||||
import types as t
|
||||
|
||||
self.config = t.SimpleNamespace(config_dir=tmp_dir)
|
||||
self.config_entries = FakeConfigEntries()
|
||||
|
||||
@staticmethod
|
||||
async def async_add_executor_job(fn, *args):
|
||||
return fn(*args)
|
||||
|
||||
|
||||
def _form_module():
|
||||
_install_stubs()
|
||||
if "nsh.helpers" not in sys.modules:
|
||||
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
|
||||
if "nsh.config_flow" not in sys.modules:
|
||||
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
|
||||
return sys.modules["nsh.config_flow"]
|
||||
|
||||
|
||||
def _entry_module():
|
||||
_install_stubs()
|
||||
if "nsh.entry" not in sys.modules:
|
||||
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
|
||||
return sys.modules["nsh.entry"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_stubs():
|
||||
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_install_stubs()
|
||||
yield
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
||||
"""ConfigFlow with the real client factory mocked to our fake."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(tmp_dir)
|
||||
flow._user_data = {
|
||||
"name": "mdarcy",
|
||||
CONF_JELLYFIN_ENTITY: "",
|
||||
CONF_RECEIVER_ENTITY: "",
|
||||
CONF_RECEIVER_INPUT: "",
|
||||
CONF_POWER_ENTITY: "",
|
||||
CONF_ADB_ENABLED: False,
|
||||
CONF_ADB_STORE_APP_INFO: True,
|
||||
CONF_HOST: "shield.local", # t41: ONE address field
|
||||
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
}
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
|
||||
monkeypatch.setattr(
|
||||
ShieldConnectionManager,
|
||||
"build_protocol_client",
|
||||
lambda *args, **kwargs: client,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"protocol_cert_paths",
|
||||
lambda hass: ("/tmp/nsh-proto-test.pem", "/tmp/nsh-proto-test.key"),
|
||||
)
|
||||
return flow
|
||||
|
||||
|
||||
class TestProtocolPairing:
|
||||
async def test_first_visit_starts_pairing_and_shows_code_form(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||
|
||||
result = await flow.async_step_protocol_pair(None)
|
||||
|
||||
assert client.cert_generated is True
|
||||
assert client.pairing_started is True
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "protocol_pair"
|
||||
assert result["errors"] == {}
|
||||
|
||||
async def test_submit_finishes_with_code_and_records_mac_unique_id(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||
|
||||
await flow.async_step_protocol_pair(None)
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
|
||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||
assert flow._unique_id == FAKE_MAC
|
||||
assert client.disconnected == 1
|
||||
# t56: a finished pairing hands over to the setup menu; its Finish row
|
||||
# creates the entry
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
||||
assert result["options"][CONF_HOST] == "shield.local"
|
||||
|
||||
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||
|
||||
await flow.async_step_protocol_pair(None)
|
||||
failed = await flow.async_step_protocol_pair({"pairing_code": "9999"})
|
||||
assert failed["type"] == "form"
|
||||
assert failed["errors"] == {"base": "protocol_pair_failed"}
|
||||
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
assert result["type"] == "menu"
|
||||
result = await flow.async_step_finish(None)
|
||||
assert result["type"] == "create_entry"
|
||||
assert client.finished_codes == [FAKE_PAIRING_CODE]
|
||||
|
||||
|
||||
class TestReauthFlow:
|
||||
async def test_reauth_repairs_entry_and_schedules_reload(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
flow = make_flow(str(tmp_path), monkeypatch, client)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1", title="mdarcy",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
data={},
|
||||
)
|
||||
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
||||
flow.context = {"entry_id": "e1"}
|
||||
|
||||
shown = await flow.async_step_reauth({CONF_ADB_PORT: DEFAULT_ADB_PORT})
|
||||
assert shown["type"] == "form"
|
||||
assert shown["step_id"] == "reauth_confirm"
|
||||
|
||||
pair_form = await flow.async_step_reauth_confirm({"confirm": True})
|
||||
assert pair_form["step_id"] == "protocol_pair" # PIN form open on device
|
||||
|
||||
result = await flow.async_step_protocol_pair(
|
||||
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
|
||||
)
|
||||
assert result == {"type": "abort", "reason": "reauth_successful"}
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True
|
||||
assert flow.hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||
|
||||
|
||||
class TestEntryMigration:
|
||||
"""t41: v2 → v3 collapses the two address fields into CONF_HOST.
|
||||
|
||||
The protocol host wins, else the ADB host (both name the same Shield); the
|
||||
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
|
||||
travels the whole way in one call.
|
||||
"""
|
||||
|
||||
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=1,
|
||||
data={},
|
||||
options={
|
||||
"adb_enabled": True,
|
||||
"adb_port": 5555,
|
||||
"jellyfin_entity": "jf.x",
|
||||
"adb_host": "10.0.0.31", # legacy address
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
# ADB options carried over byte-compatibly
|
||||
assert entry.options["adb_enabled"] is True
|
||||
assert entry.options["adb_port"] == 5555
|
||||
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_HOST] == "10.0.0.31"
|
||||
assert "adb_host" not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
|
||||
CONF_ADB_HOST: "10.0.0.1",
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.2"
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.1"
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
|
||||
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == ""
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_the_current_version_is_a_noop(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=3,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
assert hass.config_entries.updating_entry_calls == []
|
||||
|
||||
async def test_downgrade_rejected(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(version=4, options={}, title="x")
|
||||
assert await module.async_migrate_entry(hass, entry) is False
|
||||
@@ -0,0 +1,817 @@
|
||||
"""t56: the config flow against a REAL Home Assistant install.
|
||||
|
||||
The clearing semantics the live bug turned on ("a configured script cannot be
|
||||
removed") cannot be established from hand-written stubs: what matters is what
|
||||
Home Assistant itself does when a selector is cleared and how it serialises the
|
||||
form it hands the browser. So this file drives the REAL
|
||||
``homeassistant.data_entry_flow.FlowManager`` with the REAL integration, real
|
||||
``probatio`` validation and the real frontend serialiser
|
||||
(``probatio.to_field_list`` + ``cv.custom_serializer``) — the exact call the
|
||||
HTTP flow view makes.
|
||||
|
||||
Skips cleanly when homeassistant is not installed (every other test file runs
|
||||
against stubs). Run it with:
|
||||
|
||||
uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py -q
|
||||
|
||||
What this file CANNOT prove: there is no browser here, so nothing about how a
|
||||
form LOOKS. What it proves is the wire contract — the field list the frontend
|
||||
receives and the exact ``user_input`` a submission produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real FlowManager is the point of this file",
|
||||
)
|
||||
|
||||
probatio = pytest.importorskip("probatio")
|
||||
|
||||
import importlib.util # noqa: E402
|
||||
import sys # noqa: E402
|
||||
from pathlib import Path # noqa: E402
|
||||
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
OptionsFlowManager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.data_entry_flow import FlowManager # noqa: E402
|
||||
from homeassistant.helpers import config_validation as cv # noqa: E402
|
||||
from homeassistant.helpers import device_registry as device_registry # noqa: E402
|
||||
from homeassistant.helpers import entity_registry as er # noqa: E402
|
||||
from homeassistant.helpers.entity_registry import ( # noqa: E402
|
||||
EntityRegistryItems,
|
||||
EntityRegistrySettings,
|
||||
)
|
||||
|
||||
# Load the integration by PATH under its real package name: the repo is not an
|
||||
# installed distribution, and the dotted import would need custom_components/ on
|
||||
# sys.path plus every one of its siblings present.
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
cf = importlib.import_module("custom_components.nvidia_shield.config_flow")
|
||||
|
||||
SCRIPTS = {"turn_on": "script.shield_wake", "volume_set": "script.shield_volume"}
|
||||
OPTIONS = {
|
||||
"app_name_map": {"ar.tvplayer.tv": "TiviMate"},
|
||||
"command_scripts": dict(SCRIPTS),
|
||||
"host": "10.0.0.9",
|
||||
"adb_enabled": True,
|
||||
}
|
||||
SCRIPT_COMMANDS = (
|
||||
"turn_on",
|
||||
"turn_off",
|
||||
"volume_up",
|
||||
"volume_down",
|
||||
"volume_set",
|
||||
"volume_mute",
|
||||
"select_source",
|
||||
)
|
||||
SCRIPT_FIELD_PREFIX = "script_"
|
||||
|
||||
|
||||
def field_list(schema):
|
||||
"""EXACTLY what the HTTP flow view serialises for the browser."""
|
||||
if schema is None:
|
||||
return []
|
||||
return probatio.to_field_list(schema, custom_serializer=cv.custom_serializer)
|
||||
|
||||
|
||||
def field_names(schema):
|
||||
return [field["name"] for field in field_list(schema)]
|
||||
|
||||
|
||||
def suggested(schema):
|
||||
return {
|
||||
field["name"]: (field.get("description") or {}).get("suggested_value")
|
||||
for field in field_list(schema)
|
||||
}
|
||||
|
||||
|
||||
#: Entities the probe refers to. The real EntitySelector validates an id
|
||||
#: against the entity registry, so these have to exist for a set (as opposed to
|
||||
#: a clear) to be a legal submission.
|
||||
ENTITIES = (
|
||||
"script.shield_wake",
|
||||
"script.shield_volume",
|
||||
"script.kept",
|
||||
"script.wake",
|
||||
"media_player.jellyfin",
|
||||
"switch.soundbar",
|
||||
"switch.plug",
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with just enough machinery to submit real forms.
|
||||
|
||||
HomeAssistant binds the running loop in __init__, so this has to be an async
|
||||
fixture — which is also why the tests below are coroutines.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
# the real manager, exactly as core builds it — minus the reload SCHEDULE:
|
||||
# core would immediately set the domain up, which needs the integration
|
||||
# loader and the whole component bootstrap this probe deliberately skips.
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
# the real registry, populated with the entities the probe uses: a real
|
||||
# EntitySelector refuses an id the registry does not know
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
# EntityRegistry's own loader reaches deep into core (device migration,
|
||||
# restore, store), so the two containers it fills are set up directly —
|
||||
# same objects, no core bootstrap.
|
||||
registry = er.async_get(hass)
|
||||
registry.entities = EntityRegistryItems(hass)
|
||||
registry.deleted_entities = {}
|
||||
registry.settings = EntityRegistrySettings()
|
||||
registry._entities_data = registry.entities.data
|
||||
hass.data[er.DATA_REGISTRY] = registry
|
||||
for entity_id in ENTITIES:
|
||||
domain, object_id = entity_id.split(".", 1)
|
||||
registry.async_get_or_create(
|
||||
domain, "test", entity_id, suggested_object_id=object_id
|
||||
)
|
||||
assert registry.async_get("script.kept") is not None
|
||||
return hass
|
||||
|
||||
|
||||
def make_entry(hass, options=None):
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain="nvidia_shield",
|
||||
title=f"Shield {len(hass.config_entries._entries) + 1}",
|
||||
data={},
|
||||
options=dict(options if options is not None else OPTIONS),
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
# the probe "saves" by writing back what create_entry returned, exactly as
|
||||
# core's async_update_entry would; ConfigEntry exposes options through a
|
||||
# read-only property, so the private store is written instead
|
||||
object.__setattr__(entry, "_options", dict(entry.options))
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
return entry
|
||||
|
||||
|
||||
|
||||
class SetupManager(FlowManager):
|
||||
"""A real FlowManager that builds the integration's real config flow.
|
||||
|
||||
Overrides only the handler construction, and does there what core does at
|
||||
config_entries.py:1959 — the flow's ``init_step`` is the SOURCE, which is
|
||||
why a real config flow starts at ``async_step_user`` and not at ``init``.
|
||||
"""
|
||||
|
||||
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||
flow = cf.NvidiaShieldConfigFlow()
|
||||
flow.init_step = (context or {}).get("source", SOURCE_USER)
|
||||
return flow
|
||||
|
||||
async def async_finish_flow(self, flow, result):
|
||||
return result
|
||||
|
||||
|
||||
def setup_manager(hass):
|
||||
return SetupManager(hass)
|
||||
|
||||
|
||||
async def open_setup(hass):
|
||||
"""Start a real user-initiated setup flow and return (manager, result)."""
|
||||
manager = setup_manager(hass)
|
||||
result = await manager.async_init(
|
||||
"nvidia_shield", context={"source": SOURCE_USER}
|
||||
)
|
||||
return manager, result
|
||||
|
||||
|
||||
#: The first setup form, as the real frontend submits it: EVERY rendered field.
|
||||
def first_form(**overrides):
|
||||
payload = {
|
||||
"name": "Shield",
|
||||
"jellyfin_entity": "",
|
||||
"receiver_entity": "",
|
||||
"receiver_input": "",
|
||||
"power_entity": "",
|
||||
"adb_enabled": False,
|
||||
"adb_store_app_info": True,
|
||||
"host": "10.0.0.9",
|
||||
"adb_port": 5555,
|
||||
"protocol_enabled": False,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class OptionsManager(OptionsFlowManager):
|
||||
"""OptionsFlowManager that builds the integration's real options flow.
|
||||
|
||||
Overrides ONLY the handler lookup: real HA resolves it through
|
||||
``async_get_options_flow`` from the registered integration, which needs a
|
||||
fully loaded core. Everything else — menu dispatch, schema validation,
|
||||
``next_step_id`` routing — is the real implementation.
|
||||
"""
|
||||
|
||||
async def async_create_flow(self, handler_key, *, context=None, data=None):
|
||||
return cf.NvidiaShieldOptionsFlow()
|
||||
|
||||
|
||||
def options_manager(hass):
|
||||
return OptionsManager(hass)
|
||||
|
||||
|
||||
async def open_options(hass, entry):
|
||||
manager = options_manager(hass)
|
||||
result = await manager.async_init(
|
||||
entry.entry_id,
|
||||
context={"source": SOURCE_USER, "entry_id": entry.entry_id},
|
||||
)
|
||||
return manager, result
|
||||
|
||||
|
||||
def full_script_page(value=""):
|
||||
"""A real submission carries EVERY rendered field — this is that page."""
|
||||
return {f"{SCRIPT_FIELD_PREFIX}{c}": value for c in SCRIPT_COMMANDS}
|
||||
|
||||
|
||||
async def open_scripts(hass, entry):
|
||||
"""Open the Scripts sub-screen through the real menu."""
|
||||
manager, menu = await open_options(hass, entry)
|
||||
scripts = await manager.async_configure(menu["flow_id"], {"next_step_id": "scripts"})
|
||||
assert scripts["type"] == "form", scripts
|
||||
return manager, scripts
|
||||
|
||||
|
||||
class TestRealMenuFlow:
|
||||
"""The options page is a real menu with real sub-screens."""
|
||||
|
||||
async def test_the_page_is_a_menu_and_every_row_opens(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
assert menu["type"] == "menu"
|
||||
assert menu["step_id"] == "init"
|
||||
assert set(menu["menu_options"]) == {
|
||||
"connection",
|
||||
"receiver",
|
||||
"apps",
|
||||
"discover",
|
||||
"scripts",
|
||||
"finish",
|
||||
}
|
||||
# HA renders the rows from the flow result, and validates the choice
|
||||
# against the menu's own schema
|
||||
assert field_names(menu["data_schema"]) == ["next_step_id"]
|
||||
|
||||
# each row is opened on its own flow, because a real flow keeps ONE
|
||||
# step at a time — reopening from a fresh menu is what the browser does
|
||||
for row in menu["menu_options"]:
|
||||
if row in ("finish", "discover"):
|
||||
# actions: `finish` commits and `discover` sweeps, neither renders
|
||||
continue
|
||||
_manager, fresh = await open_options(ha, entry)
|
||||
opened = await _manager.async_configure(
|
||||
fresh["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] == "form", row
|
||||
# HA dispatches a submitted form by its OWN step_id, so the rendered
|
||||
# step_id must be the menu row id (the method name)
|
||||
assert opened["step_id"] == row
|
||||
|
||||
async def test_an_unknown_menu_row_is_rejected_by_the_real_schema(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
from homeassistant.data_entry_flow import InvalidData
|
||||
|
||||
# real HA validates the choice against the menu schema (probatio.In on
|
||||
# the offered ids) before the flow is even dispatched
|
||||
with pytest.raises(InvalidData):
|
||||
await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "not_a_step"}
|
||||
)
|
||||
|
||||
|
||||
class TestRealAppsField:
|
||||
"""[object Object]: the field list must carry TEXT, never a dict."""
|
||||
|
||||
async def test_the_apps_suggestion_is_the_serialized_text(self, ha):
|
||||
entry = make_entry(ha)
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
|
||||
suggested_values = suggested(apps["data_schema"])
|
||||
assert suggested_values["app_name_map"] == "ar.tvplayer.tv=TiviMate"
|
||||
assert isinstance(suggested_values["app_name_map"], str)
|
||||
|
||||
# and nothing in the whole field list carries a dict for a text field
|
||||
for field in field_list(apps["data_schema"]):
|
||||
value = (field.get("description") or {}).get("suggested_value")
|
||||
if field.get("type") == "string":
|
||||
assert not isinstance(value, (dict, list)), field["name"]
|
||||
|
||||
async def test_the_empty_apps_state_renders_without_a_suggestion(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
|
||||
fields = field_list(apps["data_schema"])
|
||||
assert [f["name"] for f in fields] == ["app_name_map"]
|
||||
assert (fields[0].get("description") or {}).get("suggested_value") in (None, "")
|
||||
|
||||
async def test_a_table_row_round_trips_through_the_real_flow(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9"})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
apps = await manager.async_configure(menu["flow_id"], {"next_step_id": "apps"})
|
||||
|
||||
saved = await manager.async_configure(
|
||||
apps["flow_id"],
|
||||
{"app_name_map": "com.plex.hdx=Plex|http://icons.local/plex.png"},
|
||||
)
|
||||
assert saved["type"] == "menu" # back at the menu, nothing committed yet
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
assert created["data"]["app_icon_map"] == {
|
||||
"com.plex.hdx": "http://icons.local/plex.png"
|
||||
}
|
||||
|
||||
|
||||
class TestRealScriptClearing:
|
||||
"""The live bug: what the REAL flow stores when a selector is cleared."""
|
||||
|
||||
async def test_set_then_cleared_persists_through_save_and_reopen(self, ha):
|
||||
"""t56 unit 0 / t57 F4: SET, CLEAR, SAVE, REOPEN — the live symptom.
|
||||
|
||||
The whole page goes through the real flow manager: first with a script
|
||||
SELECTED (an entity id, which the seeded registry resolves), then with
|
||||
that one selector cleared while the other keeps its value. Both halves
|
||||
are driven here — the earlier version of this file claimed entity ids
|
||||
could not be driven, which was simply false (the receiver page in this
|
||||
file has always submitted ``switch.soundbar``).
|
||||
"""
|
||||
entry = make_entry(ha)
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
# 1. SET: two commands selected through the real flow, from real ids
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "script.kept"
|
||||
page["script_volume_set"] = SCRIPTS["volume_set"]
|
||||
after_set = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert after_set["type"] == "menu", after_set
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_set["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["data"]["command_scripts"] == {
|
||||
"turn_on": "script.kept",
|
||||
"volume_set": SCRIPTS["volume_set"],
|
||||
}
|
||||
|
||||
# 2. CLEAR: the same page with ONE selector emptied (a cleared selector
|
||||
# arrives as ''), the other left set
|
||||
saved = dict(entry.options)
|
||||
saved.update(created["data"])
|
||||
object.__setattr__(entry, "_options", saved)
|
||||
manager2, scripts2 = await open_scripts(ha, entry)
|
||||
assert suggested(scripts2["data_schema"])["script_turn_on"] == "script.kept"
|
||||
|
||||
cleared = full_script_page("")
|
||||
cleared["script_turn_on"] = ""
|
||||
cleared["script_volume_set"] = SCRIPTS["volume_set"]
|
||||
after_clear = await manager2.async_configure(scripts2["flow_id"], cleared)
|
||||
assert after_clear["type"] == "menu", after_clear
|
||||
created2 = await manager2.async_configure(
|
||||
after_clear["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created2["data"]["command_scripts"] == {
|
||||
"volume_set": SCRIPTS["volume_set"],
|
||||
}
|
||||
|
||||
# 3. PERSIST: re-open over the SAVED options — the cleared command must
|
||||
# not come back, and no flat script_* key was ever stored
|
||||
saved2 = dict(entry.options)
|
||||
saved2.update(created2["data"])
|
||||
object.__setattr__(entry, "_options", saved2)
|
||||
assert not [k for k in entry.options if k.startswith(SCRIPT_FIELD_PREFIX)]
|
||||
manager3, scripts3 = await open_scripts(ha, entry)
|
||||
values = suggested(scripts3["data_schema"])
|
||||
assert values["script_volume_set"] == SCRIPTS["volume_set"]
|
||||
assert values.get("script_turn_on") in (None, "")
|
||||
|
||||
@pytest.mark.parametrize("shape", ["empty_string", "null"])
|
||||
async def test_a_cleared_selector_is_accepted_by_the_real_schema(self, ha, shape):
|
||||
"""Real HA rejects '' / None for a plain EntitySelector; ours tolerates it."""
|
||||
entry = make_entry(ha)
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "" if shape == "empty_string" else None
|
||||
saved = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert saved["type"] == "menu", saved
|
||||
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["command_scripts"] == {}
|
||||
|
||||
async def test_a_never_set_field_stays_out_of_the_dict(self, ha):
|
||||
entry = make_entry(ha, options={"host": "10.0.0.9", "adb_enabled": True})
|
||||
manager, scripts = await open_scripts(ha, entry)
|
||||
|
||||
# a real submission carries every rendered field, all empty here
|
||||
saved = await manager.async_configure(scripts["flow_id"], full_script_page(""))
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["command_scripts"] == {}
|
||||
|
||||
|
||||
class TestRealSelectorValidation:
|
||||
"""The selector distinction, pinned on the selector classes themselves.
|
||||
|
||||
``TestRealScriptClearing`` drives a cleared page end to end; this class pins
|
||||
the underlying difference in isolation, so a regression says WHICH rule broke:
|
||||
the built-in EntitySelector rejects a cleared field, ours accepts it and
|
||||
still validates a real id.
|
||||
"""
|
||||
|
||||
def test_real_entity_selector_rejects_a_cleared_field(self):
|
||||
from homeassistant.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
)
|
||||
|
||||
strict = EntitySelector(EntitySelectorConfig(domain="script"))
|
||||
for cleared in ("", None):
|
||||
with pytest.raises(probatio.Invalid):
|
||||
strict(cleared)
|
||||
|
||||
def test_the_clearing_selector_accepts_cleared_and_real_values(self):
|
||||
from custom_components.nvidia_shield.config_flow import _build_selectors
|
||||
|
||||
selector = _build_selectors()["script"]
|
||||
assert selector("") == ""
|
||||
assert selector(None) == ""
|
||||
assert selector(" ") == ""
|
||||
assert selector("script.whatever") == "script.whatever"
|
||||
|
||||
class TestRealNoLostEdits:
|
||||
"""Edits made on one sub-screen survive a submission on another."""
|
||||
|
||||
async def test_connection_then_scripts_then_apps_all_survive(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
|
||||
connection = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "connection"}
|
||||
)
|
||||
after_connection = await manager.async_configure(
|
||||
connection["flow_id"],
|
||||
{
|
||||
"host": "10.0.0.44",
|
||||
"adb_port": 5555,
|
||||
"adb_enabled": True,
|
||||
"protocol_enabled": False,
|
||||
"jellyfin_entity": "media_player.jellyfin",
|
||||
},
|
||||
)
|
||||
assert after_connection["type"] == "menu"
|
||||
|
||||
scripts = await manager.async_configure(
|
||||
after_connection["flow_id"], {"next_step_id": "scripts"}
|
||||
)
|
||||
page = full_script_page("")
|
||||
page["script_turn_on"] = "script.wake"
|
||||
after_scripts = await manager.async_configure(scripts["flow_id"], page)
|
||||
assert after_scripts["type"] == "menu"
|
||||
|
||||
apps = await manager.async_configure(
|
||||
after_scripts["flow_id"], {"next_step_id": "apps"}
|
||||
)
|
||||
after_apps = await manager.async_configure(
|
||||
apps["flow_id"], {"app_name_map": "com.plex.hdx=Plex"}
|
||||
)
|
||||
assert after_apps["type"] == "menu"
|
||||
|
||||
created = await manager.async_configure(
|
||||
after_apps["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
data = created["data"]
|
||||
# every earlier sub-screen's edit is still there
|
||||
assert data["host"] == "10.0.0.44"
|
||||
assert data["jellyfin_entity"] == "media_player.jellyfin"
|
||||
assert data["command_scripts"] == {"turn_on": "script.wake"}
|
||||
assert data["app_name_map"] == {"com.plex.hdx": "Plex"}
|
||||
|
||||
|
||||
class TestRealReceiverSelector:
|
||||
"""The Receiver field accepts a switch, not only a media_player."""
|
||||
|
||||
async def test_the_selector_offers_switch_domains(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
receiver = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
|
||||
fields = {f["name"]: f for f in field_list(receiver["data_schema"])}
|
||||
selector = fields["receiver_entity"]["selector"]["entity"]
|
||||
assert "switch" in selector["domain"]
|
||||
assert "media_player" in selector["domain"]
|
||||
|
||||
async def test_a_switch_is_accepted_and_stored(self, ha):
|
||||
entry = make_entry(ha, options={})
|
||||
manager, menu = await open_options(ha, entry)
|
||||
receiver = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
|
||||
saved = await manager.async_configure(
|
||||
receiver["flow_id"],
|
||||
{"receiver_entity": "switch.soundbar", "receiver_input": "HDMI 2"},
|
||||
)
|
||||
created = await manager.async_configure(
|
||||
saved["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["data"]["receiver_entity"] == "switch.soundbar"
|
||||
assert created["data"]["receiver_input"] == "HDMI 2"
|
||||
|
||||
|
||||
class TestRealSetupMenu:
|
||||
"""t57 UNIT 0a / F2: the SETUP flow under a REAL FlowManager.
|
||||
|
||||
The stub suite drove `async_step_menu` directly, which is exactly how a
|
||||
broken menu stays green: it never asked Home Assistant to dispatch a row.
|
||||
These tests do, for every row, and they are the red proof for the TypeError
|
||||
the first form used to hit on the way to the menu.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _no_connect(result=True):
|
||||
async def _connect(*args, **kwargs):
|
||||
return result
|
||||
|
||||
return staticmethod(_connect)
|
||||
|
||||
async def test_the_first_form_leads_into_pairing_then_the_menu(self, ha, monkeypatch):
|
||||
"""The whole ordinary path: first form → ADB pairing → the menu."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
assert form["type"] == "form"
|
||||
assert form["step_id"] == "user"
|
||||
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
assert pairing["type"] == "form"
|
||||
assert pairing["step_id"] == "adb_pair"
|
||||
|
||||
result = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
assert result["type"] == "menu", result
|
||||
# ADB is paired, the protocol is off: only the two sub-screens + finish
|
||||
assert list(result["menu_options"]) == ["receiver", "connection", "finish"]
|
||||
|
||||
async def test_the_adb_skip_reaches_the_menu(self, ha, monkeypatch):
|
||||
"""The path that used to die: skip ADB pairing, then render the menu."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
assert pairing["step_id"] == "adb_pair"
|
||||
|
||||
result = await manager.async_configure(
|
||||
pairing["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
|
||||
assert result["type"] == "menu", result
|
||||
assert "adb_pair" in result["menu_options"] # not paired: still offered
|
||||
|
||||
async def test_adb_pairing_success_stops_offering_that_row(self, ha, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
paired = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
assert paired["type"] == "menu"
|
||||
assert "adb_pair" not in paired["menu_options"] # F2: the guard
|
||||
|
||||
async def test_every_setup_menu_row_dispatches_and_renders(self, ha, monkeypatch):
|
||||
"""Every row HA offers must have a handler AND render a form whose
|
||||
step_id is that same row, or the page dead-ends on submit."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
# the protocol row builds the androidtvremote2 client; this test is about
|
||||
# DISPATCH, and the pairing internals have their own stub coverage. The
|
||||
# UNREACHABLE branch is a real code path needing no library, so the row
|
||||
# renders its real form.
|
||||
async def _unreachable(self):
|
||||
return None, "protocol_unreachable"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||
)
|
||||
|
||||
manager, form = await open_setup(ha)
|
||||
menu_result = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
first_form(adb_enabled=True, protocol_enabled=True),
|
||||
)
|
||||
assert menu_result["type"] == "form" # the ADB pairing form comes first
|
||||
|
||||
# hand the ADB form a skip so the MENU renders with every row
|
||||
menu_result = await manager.async_configure(
|
||||
menu_result["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
assert menu_result["type"] == "menu", menu_result
|
||||
rows = list(menu_result["menu_options"])
|
||||
assert rows == ["receiver", "connection", "adb_pair", "protocol_pair", "finish"]
|
||||
|
||||
for row in rows:
|
||||
# each row on its own flow (a flow is at one step at a time) AND on
|
||||
# the manager that owns it: a flow lives in its manager's progress
|
||||
row_manager, fresh = await open_setup(ha)
|
||||
pairing = await row_manager.async_configure(
|
||||
fresh["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||
)
|
||||
menu = await row_manager.async_configure(
|
||||
pairing["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
opened = await row_manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": row}
|
||||
)
|
||||
assert opened["type"] in ("form", "menu", "create_entry"), (row, opened)
|
||||
if opened["type"] == "form":
|
||||
assert opened["step_id"] == row, (row, opened["step_id"])
|
||||
|
||||
async def test_a_setup_sub_screen_submission_is_accepted(self, ha, monkeypatch):
|
||||
"""A rendered sub-screen must be re-submittable (row id == step_id)."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
assert menu["type"] == "menu"
|
||||
|
||||
opened = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
assert opened["type"] == "form"
|
||||
assert opened["step_id"] == "receiver"
|
||||
|
||||
back = await manager.async_configure(
|
||||
opened["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||
)
|
||||
assert back["type"] == "menu", back
|
||||
|
||||
created = await manager.async_configure(
|
||||
back["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||
|
||||
async def test_the_full_menu_walk_merges_and_creates_one_entry(self, ha, monkeypatch):
|
||||
"""connection + receiver + finish → ONE entry carrying both edits."""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(True)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=False)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"adb_port": 5555})
|
||||
|
||||
connection = await manager.async_configure(
|
||||
menu["flow_id"], {"next_step_id": "connection"}
|
||||
)
|
||||
assert connection["step_id"] == "connection"
|
||||
menu2 = await manager.async_configure(
|
||||
connection["flow_id"], {"host": "10.0.0.55", "adb_port": 5555,
|
||||
"adb_enabled": True, "protocol_enabled": False}
|
||||
)
|
||||
assert menu2["type"] == "menu"
|
||||
|
||||
receiver = await manager.async_configure(
|
||||
menu2["flow_id"], {"next_step_id": "receiver"}
|
||||
)
|
||||
menu3 = await manager.async_configure(
|
||||
receiver["flow_id"], {"receiver_entity": "switch.soundbar"}
|
||||
)
|
||||
|
||||
created = await manager.async_configure(
|
||||
menu3["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
assert created["type"] == "create_entry"
|
||||
assert created["options"]["host"] == "10.0.0.55"
|
||||
assert created["options"]["receiver_entity"] == "switch.soundbar"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adb_enabled", "protocol_enabled", "connect", "pairs_protocol"),
|
||||
[
|
||||
(True, False, False, False), # ADB skipped
|
||||
(True, False, True, False), # ADB paired, protocol off
|
||||
(True, True, False, False), # ADB skipped, protocol skipped
|
||||
(True, True, True, False), # ADB paired, protocol skipped
|
||||
(False, True, False, False), # protocol only
|
||||
],
|
||||
ids=["adb-skip", "adb-paired", "adb-skip+protocol-skip",
|
||||
"pair-adb", "protocol-only"],
|
||||
)
|
||||
async def test_every_ordinary_path_reaches_create_entry(
|
||||
self, ha, monkeypatch, adb_enabled, protocol_enabled, connect, pairs_protocol
|
||||
):
|
||||
"""t57 UNIT 0a: the path map.
|
||||
|
||||
Under the pre-fix tree FOUR of these five died with
|
||||
`TypeError: async_show_menu() got an unexpected keyword argument
|
||||
'errors'` the moment the ADB pairing form was submitted or skipped —
|
||||
so a new entry could not be created on any path that went near the
|
||||
menu, and the sub-screens were unreachable. Each path is driven here to
|
||||
`create_entry`.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(connect)
|
||||
)
|
||||
|
||||
async def _unreachable(self):
|
||||
return None, "protocol_unreachable"
|
||||
|
||||
monkeypatch.setattr(
|
||||
cf.NvidiaShieldConfigFlow, "_async_begin_protocol_pairing", _unreachable
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
result = await manager.async_configure(
|
||||
form["flow_id"],
|
||||
first_form(adb_enabled=adb_enabled, protocol_enabled=protocol_enabled),
|
||||
)
|
||||
assert result["type"] == "form", result
|
||||
|
||||
for _ in range(12):
|
||||
if result["type"] == "create_entry":
|
||||
break
|
||||
step = result.get("step_id")
|
||||
if step == "adb_pair":
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"], {"skip_pairing": True}
|
||||
)
|
||||
elif step == "protocol_pair":
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"],
|
||||
{"pairing_code": "1234", "host": "10.0.0.9", "skip_pairing": True},
|
||||
)
|
||||
elif result["type"] == "menu":
|
||||
# the menu must always offer a way out
|
||||
assert "finish" in result["menu_options"], result["menu_options"]
|
||||
result = await manager.async_configure(
|
||||
result["flow_id"], {"next_step_id": "finish"}
|
||||
)
|
||||
else: # pragma: no cover - the assertion below reports it
|
||||
break
|
||||
|
||||
assert result["type"] == "create_entry", result
|
||||
|
||||
async def test_the_menu_description_reports_what_is_unpaired(self, ha, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cf.ShieldConnectionManager, "test_connect", self._no_connect(False)
|
||||
)
|
||||
manager, form = await open_setup(ha)
|
||||
pairing = await manager.async_configure(
|
||||
form["flow_id"], first_form(adb_enabled=True, protocol_enabled=True)
|
||||
)
|
||||
menu = await manager.async_configure(pairing["flow_id"], {"skip_pairing": True})
|
||||
|
||||
assert menu["type"] == "menu"
|
||||
note = (menu.get("description_placeholders") or {}).get("pairing", "")
|
||||
assert "ADB" in note and "remote protocol" in note
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,865 @@
|
||||
"""Unit tests for ShieldConnectionManager + StateStore (Phase 1).
|
||||
|
||||
Driven with a fake poller — no real ADB, no Home Assistant. Covers the
|
||||
connect/reconnect backoff ladder, state expiration, listener notification,
|
||||
command delegation, and full task lifecycle.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from ha_adb_common.media_session import MediaSessionInfo
|
||||
from nsh import connection
|
||||
from nsh.connection import (
|
||||
FIELD_AWAKE,
|
||||
FIELD_SESSIONS,
|
||||
ShieldConnectionManager,
|
||||
StateStore,
|
||||
)
|
||||
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
|
||||
|
||||
|
||||
def test_connection_module_depends_on_no_homeassistant():
|
||||
"""The manager module must stay importable without HA installed."""
|
||||
with open(connection.__file__, encoding="utf-8") as fh:
|
||||
source = fh.read()
|
||||
assert "homeassistant" not in source
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""AdbMediaSessionPoller shape — canned answers, no socket."""
|
||||
|
||||
def __init__(self):
|
||||
self.connected = False
|
||||
self.connect_failures = 0
|
||||
self.disconnect_calls = 0
|
||||
self.awake = None
|
||||
self.sessions: list[MediaSessionInfo] = []
|
||||
self.apps: list[dict] = []
|
||||
self.audio_dump = ""
|
||||
self.foreground: str | None = None
|
||||
self.foreground_calls = 0
|
||||
self.shell_calls: list[str] = []
|
||||
self.pm_dumps: dict[str, str] = {}
|
||||
self.head_lines_used: int | None = "unset"
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self.connect_failures > 0:
|
||||
self.connect_failures -= 1
|
||||
self.connected = False
|
||||
return False
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.disconnect_calls += 1
|
||||
self.connected = False
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return self.connected
|
||||
|
||||
async def is_awake(self) -> bool | None:
|
||||
return self.awake if self.connected else None
|
||||
|
||||
async def poll_media_sessions(
|
||||
self, foreground_package=None, head_lines: int | None = 200
|
||||
) -> MediaSessionInfo | None:
|
||||
self.head_lines_used = head_lines
|
||||
return self.sessions[0] if self.sessions else None
|
||||
|
||||
@property
|
||||
def all_sessions(self) -> list[MediaSessionInfo]:
|
||||
return self.sessions
|
||||
|
||||
@property
|
||||
def last_dump(self) -> str | None:
|
||||
return self.audio_dump
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
return list(self.apps)
|
||||
|
||||
async def poll_foreground(self) -> str | None:
|
||||
self.foreground_calls += 1
|
||||
return self.foreground
|
||||
|
||||
async def shell(self, cmd: str) -> str | None:
|
||||
self.shell_calls.append(cmd)
|
||||
import shlex
|
||||
|
||||
for package, dump in self.pm_dumps.items():
|
||||
if package in cmd or shlex.quote(package) in cmd:
|
||||
return dump
|
||||
return None
|
||||
|
||||
async def send_media_command(self, command: str) -> bool:
|
||||
return self.connected
|
||||
|
||||
async def bring_to_foreground(self, package: str) -> bool:
|
||||
return self.connected
|
||||
|
||||
|
||||
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
|
||||
|
||||
|
||||
class TestStateStore:
|
||||
def test_set_get_roundtrip(self):
|
||||
store = StateStore(ttl=60, clock=time.monotonic)
|
||||
store.set("awake", True)
|
||||
assert store.get("awake") is True
|
||||
|
||||
def test_expired_field_returns_default(self):
|
||||
"""B5: a field older than the TTL reads as expired, not last-known."""
|
||||
now = [1000.0]
|
||||
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||
store.set(FIELD_AWAKE, True)
|
||||
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
|
||||
|
||||
assert store.get(FIELD_AWAKE) is True
|
||||
now[0] += 60.1
|
||||
assert store.get(FIELD_AWAKE) is None
|
||||
assert store.get(FIELD_SESSIONS, []) == []
|
||||
|
||||
def test_missing_field_returns_default(self):
|
||||
store = StateStore(ttl=60, clock=time.monotonic)
|
||||
assert store.get(FIELD_AWAKE) is None
|
||||
assert store.get(FIELD_SESSIONS, []) == []
|
||||
|
||||
def test_clear_immediately_expires(self):
|
||||
store = StateStore(ttl=60, clock=time.monotonic)
|
||||
store.set(FIELD_AWAKE, False)
|
||||
store.clear(FIELD_AWAKE)
|
||||
assert store.get(FIELD_AWAKE) is None
|
||||
|
||||
|
||||
class TestAppLabelCache:
|
||||
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
|
||||
|
||||
def _manager_with_dump(self, clock=None):
|
||||
now = 1000.0
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.pm_dumps["ar.tvplayer.tv"] = (
|
||||
"application-label: 'TiviMate'\n"
|
||||
"application-label-de: 'TiviMate DE'"
|
||||
)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||
)
|
||||
return manager, now
|
||||
|
||||
async def test_label_resolves_via_cycle_and_is_cached(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
|
||||
|
||||
await manager._watchdog_cycle(5.0) # drains at most one fetch
|
||||
# the task pool runs the fetch asynchronously — give it a beat
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||
|
||||
# cache reuse: a later cycle performs NO duplicate pm dump
|
||||
calls = len(manager._poller.shell_calls)
|
||||
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert len(manager._poller.shell_calls) == calls
|
||||
|
||||
async def test_resolution_failure_falls_through(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
# the dump RAN and carried no label: definitive negative, and the
|
||||
# caller's next tier (the raw package) takes over — still None here,
|
||||
# never a crash or a fabricated label
|
||||
assert manager.app_label("com.unknown.app") is None
|
||||
|
||||
async def test_label_less_package_is_a_definitive_negative(self):
|
||||
"""t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
|
||||
|
||||
The old code additionally stamped this answer on FAILURE paths; t55
|
||||
separates the two (see the retry tests below), but the negative cache
|
||||
for a dump that really ran stays — the round-trip tax must not return.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||
|
||||
assert manager.app_label("com.unknown.app") is None # enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0) # drain the fetch task
|
||||
first_dump_count = len(manager._poller.shell_calls)
|
||||
|
||||
# later cycles: ZERO additional pm dumps for the unresolvable pkg
|
||||
manager.app_label("com.unknown.app") # negative cache hit — not enqueued
|
||||
manager.app_label("com.unknown.app")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert len(manager._poller.shell_calls) == first_dump_count
|
||||
assert manager.app_label("com.unknown.app") is None # falls through
|
||||
|
||||
async def test_failed_fetch_is_not_cached_and_retries(self):
|
||||
"""t55 root cause: a fetch that could NOT run must not become a miss.
|
||||
|
||||
The live defect was a metadata fetch failing and the failure being
|
||||
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
|
||||
which left app_name showing the raw package id with no way back. Here
|
||||
the failed fetch stamps nothing, the next render re-enqueues, and the
|
||||
retry resolves the real label.
|
||||
"""
|
||||
manager, _t = self._manager_with_dump()
|
||||
real_shell = manager._poller.shell
|
||||
calls: list[str] = []
|
||||
|
||||
async def _flaky(cmd):
|
||||
calls.append(cmd)
|
||||
if "pm dump" in cmd:
|
||||
raise RuntimeError("adb shell blew up")
|
||||
return await real_shell(cmd)
|
||||
|
||||
manager._poller.shell = _flaky
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# the fetch ran and failed → NOTHING is stamped as an answer
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert [c for c in calls if "pm dump" in c]
|
||||
|
||||
# the next render enqueues again (the miss is retried, not silenced)
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
|
||||
# the retry reaches the device and resolves the label
|
||||
manager._poller.shell = real_shell
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await asyncio.sleep(0)
|
||||
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||
|
||||
async def test_shell_failure_is_not_cached_and_retries(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
|
||||
|
||||
async def _boom(_cmd):
|
||||
raise RuntimeError("adb shell blew up")
|
||||
|
||||
manager._poller.shell = _boom
|
||||
manager.app_label("ar.tvplayer.tv")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert manager._app_labels.get("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == set()
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
|
||||
|
||||
async def test_in_flight_fetch_is_not_enqueued_twice(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
|
||||
assert manager.app_label("ar.tvplayer.tv") is None
|
||||
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
|
||||
assert manager.app_label_requests == {"ar.tvplayer.tv"}
|
||||
assert manager.app_label_inflight == set()
|
||||
|
||||
async def test_one_fetch_per_cycle_even_under_a_burst(self):
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
|
||||
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
|
||||
|
||||
manager.app_label("com.a")
|
||||
manager.app_label("com.b")
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
|
||||
assert len(dumps) == 2 # one per cycle, both eventually resolved
|
||||
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
|
||||
"A",
|
||||
"B",
|
||||
]
|
||||
|
||||
async def test_pm_dump_command_is_shell_quoted(self):
|
||||
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
|
||||
import shlex
|
||||
|
||||
wicked = "com.evil;name"
|
||||
manager, _t = self._manager_with_dump()
|
||||
manager._poller.pm_dumps[wicked] = "application-label: 'E'"
|
||||
|
||||
manager.app_label(wicked) # enqueue
|
||||
await manager._watchdog_cycle(5.0)
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0) # drain the fetch task
|
||||
|
||||
# t57: the dump carries the exit marker too, so a blank answer can be
|
||||
# told from a FAILED fetch; the package is still quoted
|
||||
assert manager._poller.shell_calls == [
|
||||
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300; "
|
||||
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||
]
|
||||
|
||||
|
||||
class TestLogcatStream:
|
||||
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
|
||||
|
||||
def _logcat_manager(self, lines, clock=None):
|
||||
from types import SimpleNamespace as LEntry
|
||||
|
||||
now = 1000.0
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": bool(lines)})
|
||||
lines = lines or []
|
||||
|
||||
async def _open():
|
||||
for line in lines:
|
||||
yield line
|
||||
|
||||
manager._open_logcat_stream = _open
|
||||
|
||||
async def fake_ladder_sleep(seconds):
|
||||
return None # deterministic: no real ladder sleeps (t26)
|
||||
|
||||
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||
return manager, now
|
||||
|
||||
async def test_start_stamps_foreground_instantly(self):
|
||||
manager, _now = self._logcat_manager(
|
||||
[
|
||||
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||
" START u0 {cmp=com.netflix.ninja/x}",
|
||||
]
|
||||
)
|
||||
notified = []
|
||||
manager.register_listener(lambda: notified.append(1))
|
||||
|
||||
await manager._watchdog_cycle(5.0) # starts the consumer
|
||||
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(0) # drain the task
|
||||
assert manager.store.get("foreground") == "com.netflix.ninja"
|
||||
assert notified # immediate listener notification (sub-heartbeat)
|
||||
|
||||
async def test_duplicate_start_suppressed_inside_window(self):
|
||||
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
|
||||
tick = {"i": 0}
|
||||
|
||||
def advancing():
|
||||
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
|
||||
return stamps[tick["i"]]
|
||||
|
||||
manager, _now = self._logcat_manager(
|
||||
[
|
||||
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||
],
|
||||
clock=advancing,
|
||||
)
|
||||
store_sets = {"n": 0}
|
||||
original_set = manager.store.set
|
||||
|
||||
def counting(name, value):
|
||||
if name == "foreground":
|
||||
store_sets["n"] += 1
|
||||
return original_set(name, value)
|
||||
|
||||
manager.store.set = counting
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._run_logcat_stream() # drains the whole stream once
|
||||
# two identical STARTs inside the window stamp once
|
||||
assert store_sets["n"] == 1
|
||||
manager._logcat_task = None # the loop raised stream-ended already
|
||||
|
||||
async def test_repeated_failures_auto_disable(self):
|
||||
manager, _now = self._logcat_manager(
|
||||
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
|
||||
)
|
||||
# the stream rnux: ``_open`` raises — ladder counts the failure
|
||||
# starve the ladder: instant (no real sleep between reconnects)
|
||||
manager._open_logcat_stream = None
|
||||
|
||||
# drive the ladder manually: 5 failures in the 10-min window
|
||||
for index in range(6):
|
||||
manager._note_logcat_failure(1000.0 + index)
|
||||
|
||||
assert manager._logcat_disabled is True # auto-disable at 5
|
||||
assert manager._entry_options_logcat() is True # option unchanged
|
||||
|
||||
def test_streaming_call_carries_no_idle_timeout(self):
|
||||
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||
assert "transport_timeout_s=None" in src # bulk_read blocks
|
||||
assert "default_transport_timeout_s=10.0" not in src # old default
|
||||
|
||||
async def test_buffered_window_catches_wrapped_cmp_lines(self):
|
||||
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
|
||||
from types import SimpleNamespace as LEntry
|
||||
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": True})
|
||||
|
||||
# the START record spans two stream lines (wrapped cmp=)
|
||||
lines = [
|
||||
"03-21 10:19:47.000 3104 3456 I"
|
||||
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
|
||||
" /.ui.MainActivity flg=0x1}",
|
||||
]
|
||||
|
||||
async def _open():
|
||||
for line in lines:
|
||||
yield line
|
||||
|
||||
manager._open_logcat_stream = _open
|
||||
|
||||
async def fake_ladder_sleep(seconds):
|
||||
return None
|
||||
|
||||
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||
|
||||
raised = None
|
||||
try:
|
||||
await manager._logcat_pass()
|
||||
except Exception as err: # the pass ends with stream-ended
|
||||
raised = err
|
||||
assert raised is not None # stream-ended raises by contract
|
||||
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||
|
||||
def test_public_config_accessor_is_the_source(self):
|
||||
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||
assert "poller.config" in src # public accessor
|
||||
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
|
||||
|
||||
async def test_option_off_means_no_stream_task(self):
|
||||
manager, _now = self._logcat_manager(None) # logcat_events: falsy
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert manager._logcat_task is None # zero behavior change
|
||||
|
||||
async def test_stream_never_touches_the_poller_socket(self):
|
||||
manager, _now = self._logcat_manager(
|
||||
[
|
||||
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||
" START u0 {cmp=com.netflix.ninja/x}",
|
||||
]
|
||||
)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
|
||||
|
||||
|
||||
class TestForegroundFallback:
|
||||
async def test_stamps_field_foreground_when_protocol_silent(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert poller.foreground_calls == 1
|
||||
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||
# store priority: protocol push wins over the ADB parse
|
||||
assert manager.foreground_app == "ar.tvplayer.tv"
|
||||
|
||||
async def test_protocol_push_overrides_adb_foreground(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.foreground = "ar.tvplayer.tv"
|
||||
manager = _manager(poller)
|
||||
manager.store.set("current_app", "com.netflix.ninja") # live push
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert poller.foreground_calls == 0 # skipped — push is fresh
|
||||
assert manager.foreground_app == "com.netflix.ninja"
|
||||
|
||||
async def test_no_foreground_query_when_asleep(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = False
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert poller.foreground_calls == 0
|
||||
assert manager.store.get("foreground") is None
|
||||
|
||||
|
||||
class TestAudioPlaybackFilter:
|
||||
async def test_system_media_packages_filtered(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.audio_dump = (
|
||||
" Audio playback (lastly played comes first)\n"
|
||||
" uid=1 packages=com.google.android.apps.mediashell\n"
|
||||
" uid=2 packages=ar.tvplayer.tv\n"
|
||||
" uid=3 packages=com.android.systemui\n"
|
||||
)
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert manager.audio_playback == ["ar.tvplayer.tv"]
|
||||
|
||||
|
||||
class TestLastKnownStateCache:
|
||||
async def test_serves_last_known_when_no_session(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
# later: TiviMate stops publishing; foreground has no session
|
||||
poller.sessions = []
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
served = manager.best_session("ar.tvplayer.tv")
|
||||
assert served is not None
|
||||
assert served.playing is True # the last known state
|
||||
|
||||
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||
assert info is served
|
||||
assert stamp is not None
|
||||
|
||||
async def test_expiry_never_resurrects_playing(self):
|
||||
"""P3-2d expiry contract, deterministic via the injected clock."""
|
||||
now = 1000.0
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||
manager = _manager(poller, clock=lambda: now)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert manager.last_known_session("ar.tvplayer.tv") is not None
|
||||
|
||||
# TTL passes with no further caching — 'playing' must NOT come back
|
||||
now += manager.store.ttl + 0.05
|
||||
|
||||
assert manager.last_known_session("ar.tvplayer.tv") is None
|
||||
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
|
||||
assert manager.best_session("ar.tvplayer.tv") is None
|
||||
|
||||
async def test_state_inside_ttl_is_served(self):
|
||||
now = 1000.0
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||
manager = _manager(poller, clock=lambda: now)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
now += manager.store.ttl - 1.0 # inside the TTL
|
||||
|
||||
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||
assert info is not None and info.playing is True
|
||||
assert stamp == 1000.0 # the cycle's cache stamp
|
||||
|
||||
async def test_live_session_beats_last_known(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
# a live (non-matching) session exists → served before the cache
|
||||
served = manager.best_session("ar.tvplayer.tv")
|
||||
assert served is not None and served.package == "app.old"
|
||||
|
||||
async def test_cache_cleared_on_stop(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
await manager.async_stop()
|
||||
|
||||
assert manager._last_known == {}
|
||||
|
||||
|
||||
class TestConnectAndRefresh:
|
||||
async def test_cycle_connects_and_populates_store(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||
poller.apps = [{"package": "app.a"}]
|
||||
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
|
||||
|
||||
manager = _manager(poller)
|
||||
notified = []
|
||||
unsub = manager.register_listener(lambda: notified.append(True))
|
||||
|
||||
delay = await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert delay == ADB_POLL_INTERVAL
|
||||
assert manager.connected is True
|
||||
assert manager.awake is True
|
||||
assert manager.best_session("app.a").package == "app.a"
|
||||
assert manager.running_apps == [{"package": "app.a"}]
|
||||
assert manager.audio_playback == ["app.a"]
|
||||
assert notified, "listener must be notified after a refresh"
|
||||
unsub()
|
||||
assert manager._listeners == []
|
||||
|
||||
async def test_query_is_untruncated(self):
|
||||
"""The manager always asks for the full dump (plan blocker B4)."""
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert poller.head_lines_used is None
|
||||
|
||||
async def test_standby_expires_playback_fields(self):
|
||||
"""Asleep → sessions/audio/apps are expired, awake stays False."""
|
||||
poller = FakePoller()
|
||||
poller.awake = False
|
||||
manager = _manager(poller)
|
||||
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
|
||||
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert manager.awake is False
|
||||
assert manager.active_sessions == []
|
||||
assert manager.audio_playback == []
|
||||
assert manager.running_apps == []
|
||||
|
||||
async def test_awake_none_is_a_cycle_failure(self):
|
||||
"""Wake check returning None must back off instead of storing unknown."""
|
||||
poller = FakePoller()
|
||||
poller.connected = True
|
||||
manager = _manager(poller)
|
||||
manager.store.set(FIELD_AWAKE, True)
|
||||
|
||||
delay = await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert delay is not None
|
||||
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
|
||||
assert manager.awake is None # stale True dropped
|
||||
|
||||
async def test_foreground_preference_and_playing_order(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
# parse order convention: playing first
|
||||
poller.sessions = [
|
||||
MediaSessionInfo(package="app.playing", playing=True),
|
||||
MediaSessionInfo(package="app.paused", paused=True),
|
||||
]
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert manager.best_session("app.paused").package == "app.paused"
|
||||
assert manager.best_session().package == "app.playing"
|
||||
assert manager.best_session("app.unknown").package == "app.playing"
|
||||
|
||||
|
||||
class TestReconnectBackoff:
|
||||
async def test_failed_connect_returns_backoff_scaled_delay(self):
|
||||
poller = FakePoller()
|
||||
poller.connect_failures = 1
|
||||
manager = _manager(poller)
|
||||
|
||||
delay = await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert not poller.connected
|
||||
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
|
||||
assert manager._failures == 1
|
||||
|
||||
async def test_backoff_doubles_up_to_the_cap(self):
|
||||
poller = FakePoller()
|
||||
poller.connect_failures = 1
|
||||
manager = _manager(poller)
|
||||
|
||||
delays = []
|
||||
backoffs = []
|
||||
backoff = 5.0
|
||||
for _ in range(7):
|
||||
backoffs.append(backoff)
|
||||
delays.append(await manager._watchdog_cycle(backoff))
|
||||
backoff = min(backoff * 2, 300.0)
|
||||
|
||||
# F5 soundness: the previous ±25 % jitter could flake the claim
|
||||
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
|
||||
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
|
||||
# jitter guarantees: each delay sits inside ITS OWN backoff's band
|
||||
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
|
||||
# since min next = 7.5 >= max first = 6.25), and never above the cap.
|
||||
for backoff, delay in zip(backoffs, delays, strict=True):
|
||||
assert 0.75 * backoff <= delay <= 1.25 * backoff
|
||||
assert delays[1] >= delays[0]
|
||||
assert delays[-1] <= 300.0 * 1.25
|
||||
|
||||
async def test_repeated_failures_do_not_spam_listeners(self):
|
||||
poller = FakePoller()
|
||||
poller.connect_failures = 99
|
||||
manager = _manager(poller)
|
||||
notified = []
|
||||
manager.register_listener(lambda: notified.append(True))
|
||||
|
||||
for _ in range(3):
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert notified.count(None) == 0
|
||||
assert len(notified) == 1, "down-notified exactly once until recovery"
|
||||
|
||||
async def test_recovery_resets_failures_and_backoff(self):
|
||||
poller = FakePoller()
|
||||
poller.connect_failures = 0
|
||||
poller.awake = True
|
||||
manager = _manager(poller)
|
||||
|
||||
await manager._watchdog_cycle(5.0) # fresh connect → ok
|
||||
assert manager._failures == 0
|
||||
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
|
||||
|
||||
async def test_failed_wake_poll_forces_reconnect(self):
|
||||
"""health_check drives the reconnect decision: a cycle where is_awake
|
||||
fails closes the socket so the next cycle reconnects from scratch."""
|
||||
poller = FakePoller()
|
||||
poller.connected = True
|
||||
poller.awake = None # wake poll fails while the flag says "connected"
|
||||
manager = _manager(poller)
|
||||
|
||||
delay = await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
|
||||
assert poller.disconnect_calls == 1
|
||||
assert poller.connected is False
|
||||
assert manager._failures == 1
|
||||
|
||||
async def test_connect_failure_clears_stale_store_state(self):
|
||||
poller = FakePoller()
|
||||
poller.connect_failures = 1
|
||||
manager = _manager(poller)
|
||||
manager.store.set(FIELD_AWAKE, True)
|
||||
sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||
manager.store.set(FIELD_SESSIONS, sessions)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert manager.awake is None
|
||||
assert manager.active_sessions == []
|
||||
|
||||
|
||||
class TestCommandDelegation:
|
||||
async def test_send_media_command_requires_poller(self):
|
||||
manager = _manager(poller=None)
|
||||
assert manager.available is False
|
||||
assert await manager.send_media_command("play") is False
|
||||
|
||||
async def test_send_media_command_delegates(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert await manager.send_media_command("play") is True
|
||||
|
||||
async def test_bring_to_foreground(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = _manager(poller)
|
||||
await manager._watchdog_cycle(5.0)
|
||||
assert await manager.bring_to_foreground("app.a") is True
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_passive_manager_exits_immediately(self):
|
||||
"""No factory → no watchdog loop; entry stays functional without ADB."""
|
||||
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0.01)
|
||||
assert manager.available is False
|
||||
assert manager.connected is False
|
||||
assert await manager.send_media_command("play") is False
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_async_stop_disconnects_and_cancels_tasks(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = _manager(poller)
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0) # let the watchdog task start
|
||||
|
||||
assert manager.available
|
||||
await manager.async_stop()
|
||||
|
||||
assert poller.disconnect_calls == 1
|
||||
assert manager._poller is None
|
||||
assert manager._tasks == []
|
||||
assert manager._stopping is True
|
||||
|
||||
async def test_stop_is_idempotent(self):
|
||||
manager = _manager(FakePoller())
|
||||
await manager.async_stop()
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_factory_used_when_no_poller_given(self):
|
||||
poller = FakePoller()
|
||||
poller.awake = True
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller_factory=lambda: poller
|
||||
)
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0)
|
||||
await manager.async_stop()
|
||||
assert poller.disconnect_calls == 1
|
||||
|
||||
|
||||
class TestStoreTtlInManager:
|
||||
async def test_expired_session_reads_as_none(self):
|
||||
"""Full manager view: TTL expiry means 'state unavailable', not
|
||||
'last known playing'."""
|
||||
now = [1000.0]
|
||||
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||
manager = _manager(store=store)
|
||||
manager.store.set(
|
||||
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
|
||||
)
|
||||
now[0] += 60.1
|
||||
assert manager.best_session() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owned", [True, False])
|
||||
async def test_watchdog_cycle_never_raises(owned):
|
||||
"""A poller can throw anything — the cycle converts to a backoff delay."""
|
||||
poller = FakePoller()
|
||||
|
||||
class ExplodingPoller(FakePoller):
|
||||
async def is_awake(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
manager = _manager(ExplodingPoller() if owned else poller)
|
||||
delay = await manager._watchdog_cycle(5.0)
|
||||
assert delay > 0
|
||||
@@ -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,76 @@
|
||||
"""P3-2a tests: the mResumedActivity foreground parser (RESEARCH_SHIELD R4).
|
||||
|
||||
Fixture-driven from the on-device captures; the launcher case comes from the
|
||||
R4-documented custom-launcher capture. Window-focus parsing is never used
|
||||
(R4 finding 2: mCurrentFocus=null trap).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nsh.ha_adb_common import parse_foreground
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
# R4: the on-device capture (grep -iE 'resumed') — the wrapped ActivityRecord
|
||||
# plus tolerated Resumed/ResumedActivity duplicates.
|
||||
_TIVIMATE_DUMP = (FIXTURES / "activity_activities_tivimate.txt").read_text()
|
||||
|
||||
|
||||
def test_tivimate_fixture_primary_pattern():
|
||||
assert parse_foreground(_TIVIMATE_DUMP) == "ar.tvplayer.tv"
|
||||
|
||||
|
||||
def test_launcher_foreground_from_r4_capture():
|
||||
# R4 finding 4: the user's custom launcher is a legitimate foreground app.
|
||||
dump = (
|
||||
"mResumedActivity: ActivityRecord{abcd1234 u0 com.spocky.projengmenu"
|
||||
"/.ui.home.MainActivity\n"
|
||||
" state=RESUMED stopped=false delayedResume=false finishing=false\n"
|
||||
)
|
||||
assert parse_foreground(dump) == "com.spocky.projengmenu"
|
||||
|
||||
|
||||
def test_multiline_wrap_joined_before_matching():
|
||||
# The wrap splits right after <pkg>/<activity>; the package must survive.
|
||||
dump = (
|
||||
" mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity\n"
|
||||
" t8152}\n"
|
||||
)
|
||||
assert parse_foreground(dump) == "ar.tvplayer.tv"
|
||||
|
||||
|
||||
def test_topresumed_only_fallback():
|
||||
# Newer firmware: no mResumedActivity, only topResumedActivity (checked after).
|
||||
dump = (
|
||||
" topResumedActivity: ActivityRecord{aa11bb22 u0 com.other.app/.Main t1}\n"
|
||||
)
|
||||
assert parse_foreground(dump) == "com.other.app"
|
||||
|
||||
|
||||
def test_topresumed_never_beats_primary():
|
||||
dump = (
|
||||
" mResumedActivity: ActivityRecord{x u0 primary.pkg/.Main t1}\n"
|
||||
" topResumedActivity: ActivityRecord{y u0 newer.pkg/.Main t2}\n"
|
||||
)
|
||||
assert parse_foreground(dump) == "primary.pkg"
|
||||
|
||||
|
||||
def test_empty_and_garbage_return_none():
|
||||
assert parse_foreground("") is None
|
||||
assert parse_foreground("some random log lines\nwithout any resumed marker") is None
|
||||
|
||||
|
||||
def test_resumed_duplicates_tolerated():
|
||||
# Resumed: / ResumedActivity: / mResumedActivity: all describe the same app.
|
||||
dump = (
|
||||
" Resumed activities in task display areas (from top to bottom):\n"
|
||||
" Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||
" ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||
"mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
|
||||
)
|
||||
assert parse_foreground(dump) == "ar.tvplayer.tv"
|
||||
|
||||
|
||||
def test_null_package_never_returned():
|
||||
dump = "mResumedActivity: ActivityRecord{4f0d030 u0 null/null t1}\n"
|
||||
assert parse_foreground(dump) is None
|
||||
@@ -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")]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for the PlaybackIntent dataclass (optimistic state tracking)."""
|
||||
|
||||
from ha_adb_common.intent import PlaybackIntent
|
||||
|
||||
|
||||
def test_create_captures_fields():
|
||||
intent = PlaybackIntent.create(
|
||||
target_state="paused", command="media_pause", prior_state="playing"
|
||||
)
|
||||
assert intent.target_state == "paused"
|
||||
assert intent.command == "media_pause"
|
||||
assert intent.prior_state == "playing"
|
||||
assert intent.retry_count == 0
|
||||
assert intent.created_at > 0
|
||||
|
||||
|
||||
def test_debug_dict_shape():
|
||||
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||
debug = intent.to_debug_dict()
|
||||
assert debug["target"] == "playing"
|
||||
assert debug["command"] == "media_play"
|
||||
assert debug["retries"] == 0
|
||||
assert "age_s" in debug
|
||||
|
||||
|
||||
def test_age_increases():
|
||||
import time
|
||||
|
||||
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||
before = intent.age_seconds
|
||||
time.sleep(0.01)
|
||||
assert intent.age_seconds >= before
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Parser tests driven by real Shield firmware captures (Android 11, RQ1A.210105.003).
|
||||
|
||||
Fixtures live in tests/fixtures — see docs/RESEARCH_SHIELD.md for how each
|
||||
capture was produced and which expectations it pins.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ha_adb_common.media_session import (
|
||||
MediaSessionInfo,
|
||||
parse_all_media_sessions,
|
||||
parse_audio_playback,
|
||||
parse_media_session,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _fixture(name: str) -> str:
|
||||
return (FIXTURES / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestTiviMateHasNoSession:
|
||||
"""TiviMate (ar.tvplayer.tv) registers no MediaSession on this firmware."""
|
||||
|
||||
def test_no_sessions_parsed(self):
|
||||
assert parse_all_media_sessions(_fixture("media_session_tivimate.txt")) == []
|
||||
|
||||
def test_session_query_returns_none(self):
|
||||
dump = _fixture("media_session_tivimate.txt")
|
||||
assert parse_media_session(dump, "ar.tvplayer.tv") is None
|
||||
|
||||
def test_inactive_stale_session_is_not_surfaced(self):
|
||||
"""Netflix registers an inactive, state-less session; the parser must
|
||||
ignore it — an active=false block never appears in the results."""
|
||||
dump = _fixture("media_session_tivimate.txt")
|
||||
assert parse_media_session(dump, "com.netflix.ninja") is None
|
||||
|
||||
|
||||
class TestWholphinSession:
|
||||
"""Wholphin (com.github.damontecres.wholphin) publishes a proper session."""
|
||||
|
||||
def test_playing_session_parsed(self):
|
||||
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||
assert len(sessions) == 1
|
||||
session = sessions[0]
|
||||
assert session.package == "com.github.damontecres.wholphin"
|
||||
assert session.playing is True
|
||||
assert session.paused is False
|
||||
assert session.title == "Twirlywoos"
|
||||
assert session.artist is None
|
||||
|
||||
def test_foreground_package_is_preferred(self):
|
||||
dump = _fixture("media_session_wholphin.txt")
|
||||
best = parse_media_session(dump, "com.github.damontecres.wholphin")
|
||||
assert best is not None
|
||||
assert best.package == "com.github.damontecres.wholphin"
|
||||
assert best.playing is True
|
||||
|
||||
|
||||
class TestPlaybackStatePosition:
|
||||
"""PlaybackState carries position (ms), speed, and uptime-ms `updated` (R9)."""
|
||||
|
||||
def test_wholphin_position_fields_parsed(self):
|
||||
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||
assert len(sessions) == 1
|
||||
session = sessions[0]
|
||||
assert session.position == 17237
|
||||
assert session.speed == 1.0
|
||||
assert session.updated == 1408392
|
||||
|
||||
def test_buffered_position_is_not_mistaken_for_position(self):
|
||||
"""`buffered position=70070` sits in the same block; only the real
|
||||
`position=17237` may be read."""
|
||||
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
|
||||
assert sessions[0].position != 70070
|
||||
|
||||
def test_position_at_uptime_extrapolates_with_speed(self):
|
||||
session = MediaSessionInfo(
|
||||
position=17237, speed=1.0, updated=1408392, playing=True
|
||||
)
|
||||
assert session.position_at_uptime(1408392 + 1000) == 18237
|
||||
assert session.position_at_uptime(1408392) == 17237
|
||||
|
||||
def test_position_at_uptime_none_without_sample(self):
|
||||
assert MediaSessionInfo().position_at_uptime(1_000_000) is None
|
||||
|
||||
def test_paused_session_parses_state_and_position(self):
|
||||
dump = (
|
||||
"Sessions Stack - have 1 sessions:\n"
|
||||
" Some app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=PlaybackState {state=2, position=4200, buffered position=9000, "
|
||||
"speed=0.0, updated=500, actions=1, custom actions=[], active item id=0, "
|
||||
"error=null}\n"
|
||||
" metadata: size=2, description=Song Title, Some Artist\n"
|
||||
" Audio playback (lastly played comes first)\n"
|
||||
" uid=10100 packages=com.example.player\n"
|
||||
)
|
||||
sessions = parse_all_media_sessions(dump)
|
||||
assert len(sessions) == 1
|
||||
session = sessions[0]
|
||||
assert session.paused is True
|
||||
assert session.playing is False
|
||||
assert session.position == 4200
|
||||
assert session.speed == 0.0
|
||||
assert session.updated == 500
|
||||
assert session.title == "Song Title"
|
||||
assert session.artist == "Some Artist"
|
||||
|
||||
def test_stateless_playbackstate_line_ignored(self):
|
||||
"""A non-PlaybackState `state=` line must not crash or fabricate state."""
|
||||
dump = (
|
||||
" Some app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=null\n"
|
||||
" metadata: size=1, description=Only Title\n"
|
||||
)
|
||||
sessions = parse_all_media_sessions(dump)
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].playing is False
|
||||
assert sessions[0].paused is False
|
||||
assert sessions[0].position is None
|
||||
|
||||
|
||||
class TestPlaybackStateBufferingT55:
|
||||
"""t55: Android PlaybackState 6 is BUFFERING, mapped in the parser.
|
||||
|
||||
The Shield's own sessions report the same integer stock Android does (6),
|
||||
so no vendor-specific variant is treated as buffering — a device variant
|
||||
would be a template field on the same PlaybackState line, never a
|
||||
different integer.
|
||||
"""
|
||||
|
||||
def _buffering_dump(self, state=6, speed="0.0"):
|
||||
return (
|
||||
"Sessions Stack - have 1 sessions:\n"
|
||||
" Some app session com.netflix.ninja/Session (userId=0)\n"
|
||||
" package=com.netflix.ninja\n"
|
||||
" active=true\n"
|
||||
f" state=PlaybackState {{state={state}, position=0, "
|
||||
f"buffered position=2000, speed={speed}, updated=900, actions=1, "
|
||||
"custom actions=[], active item id=0, error=null}\n"
|
||||
" metadata: size=2, description=Movie Title, Some Artist\n"
|
||||
)
|
||||
|
||||
def test_state_6_is_buffering(self):
|
||||
sessions = parse_all_media_sessions(self._buffering_dump())
|
||||
assert len(sessions) == 1
|
||||
session = sessions[0]
|
||||
assert session.buffering is True
|
||||
assert session.playing is False
|
||||
assert session.paused is False
|
||||
assert session.title == "Movie Title"
|
||||
|
||||
def test_state_3_and_2_stay_playing_and_paused(self):
|
||||
for state, attr in ((3, "playing"), (2, "paused")):
|
||||
sessions = parse_all_media_sessions(self._buffering_dump(state=state))
|
||||
session = sessions[0]
|
||||
assert session.buffering is False
|
||||
assert getattr(session, attr) is True
|
||||
|
||||
def test_state_6_wins_when_the_dump_says_speed_zero_too(self):
|
||||
"""The buffering signal is the integer, not the speed field."""
|
||||
sessions = parse_all_media_sessions(self._buffering_dump(state=6))
|
||||
assert sessions[0].buffering is True
|
||||
|
||||
def test_buffering_outranks_a_paused_session_in_the_pick_order(self):
|
||||
sessions = parse_all_media_sessions(
|
||||
self._buffering_dump()
|
||||
+ " Other app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||
"error=null}\n"
|
||||
" metadata: size=1, description=Paused Tune\n"
|
||||
)
|
||||
assert [s.package for s in sessions] == [
|
||||
"com.netflix.ninja",
|
||||
"com.example.player",
|
||||
]
|
||||
# and parse_media_session (no foreground preference) picks buffering
|
||||
dump = self._buffering_dump() + (
|
||||
" Other app session com.example.player/Session (userId=0)\n"
|
||||
" package=com.example.player\n"
|
||||
" active=true\n"
|
||||
" state=PlaybackState {state=2, position=1, speed=0.0, "
|
||||
"updated=1, actions=1, custom actions=[], active item id=0, "
|
||||
"error=null}\n"
|
||||
" metadata: size=1, description=Paused Tune\n"
|
||||
)
|
||||
assert parse_media_session(dump).package == "com.netflix.ninja"
|
||||
|
||||
|
||||
class TestAudioPlaybackSection:
|
||||
"""The "Audio playback (lastly played comes first)" section lists apps
|
||||
that recently played audio even with no MediaSession (R2)."""
|
||||
|
||||
def test_tivimate_shows_up_without_a_session(self):
|
||||
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
|
||||
assert packages == [
|
||||
"ar.tvplayer.tv",
|
||||
"com.google.android.apps.mediashell",
|
||||
]
|
||||
|
||||
def test_order_is_dump_order_most_recent_first(self):
|
||||
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
|
||||
assert packages[0] == "ar.tvplayer.tv"
|
||||
|
||||
def test_wholphin_audio_playback(self):
|
||||
assert parse_audio_playback(_fixture("media_session_wholphin.txt")) == [
|
||||
"com.github.damontecres.wholphin"
|
||||
]
|
||||
|
||||
def test_lines_before_the_section_are_ignored(self):
|
||||
"""`... package:` listener lines precede the section and must not leak
|
||||
into the audio-playback package list."""
|
||||
dump = (
|
||||
" Media key listener package:\n"
|
||||
" OnMediaKeyEventDispatchedListener: added 0 listener(s)\n"
|
||||
" Audio playback (lastly played comes first)\n"
|
||||
" uid=10117 packages=ar.tvplayer.tv\n"
|
||||
)
|
||||
assert parse_audio_playback(dump) == ["ar.tvplayer.tv"]
|
||||
|
||||
def test_empty_dump(self):
|
||||
assert parse_audio_playback("") == []
|
||||
|
||||
|
||||
def test_running_apps_buffering_state_t55():
|
||||
"""t55: a buffering session (and a buffering last-known) is named buffering."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||
|
||||
tasks = [
|
||||
{"package": "com.netflix.ninja"},
|
||||
{"package": "ar.tvplayer.tv"},
|
||||
]
|
||||
sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)]
|
||||
last_known = {
|
||||
"ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True)
|
||||
}
|
||||
|
||||
apps = running_apps_from_tasks(
|
||||
tasks, sessions, foreground_package=None, last_known=last_known
|
||||
)
|
||||
|
||||
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
|
||||
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
|
||||
assert netflix["state"] == "buffering"
|
||||
assert tivi["last_known_state"] == "buffering"
|
||||
|
||||
|
||||
def test_running_apps_last_known_enrichment():
|
||||
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
|
||||
|
||||
tasks = [
|
||||
{"package": "ar.tvplayer.tv"},
|
||||
{"package": "com.netflix.ninja"},
|
||||
]
|
||||
sessions = [MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")]
|
||||
last_known = {
|
||||
"ar.tvplayer.tv": MediaSessionInfo(
|
||||
package="ar.tvplayer.tv", playing=False, paused=True, title="Last show"
|
||||
)
|
||||
}
|
||||
|
||||
apps = running_apps_from_tasks(tasks, sessions, foreground_package=None, last_known=last_known)
|
||||
|
||||
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
|
||||
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
|
||||
|
||||
assert tivi["state"] == "running" # no live session
|
||||
assert tivi["last_known_state"] == "paused"
|
||||
assert tivi["last_known_title"] == "Last show"
|
||||
assert "last_known_state" not in netflix # live session enriched instead
|
||||
assert netflix["state"] == "playing"
|
||||
|
||||
|
||||
def test_parse_application_label_default_locale_wins():
|
||||
"""P5-1: the default-locale label wins; localized suffixes are skipped."""
|
||||
from nsh.ha_adb_common.app_label import parse_application_label
|
||||
|
||||
dump = (
|
||||
"application-label: 'TiviMate'\n"
|
||||
"application-label-de: 'TiviMate DE'\n"
|
||||
"application-icon-320: 'x'"
|
||||
)
|
||||
assert parse_application_label(dump) == "TiviMate"
|
||||
|
||||
|
||||
def test_parse_application_label_tolerance():
|
||||
from nsh.ha_adb_common.app_label import parse_application_label
|
||||
|
||||
# double-quoted, empty first label (multi-label block), nonexistent
|
||||
assert parse_application_label('application-label:"Wholphin"') == "Wholphin"
|
||||
multi_label = "application-label:''\napplication-label: 'TiviMate'"
|
||||
assert parse_application_label(multi_label) == "TiviMate"
|
||||
assert parse_application_label("application-label: null") is None
|
||||
assert parse_application_label("") is None
|
||||
assert parse_application_label("nothing here") is None
|
||||
|
||||
|
||||
def _logcat_lines():
|
||||
"""Synthetic logcat list covering START shapes + noise + wrapping."""
|
||||
return [
|
||||
"03-21 10:19:40.100 3104 3456 I NotificationService: noise",
|
||||
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u0 "
|
||||
"{cmp=com.netflix.ninja/com.netflix.ui.main flg=0x10000000}",
|
||||
"03-21 10:19:45.100 3104 3456 I ActivityTaskManager: START u0 {cmp=null}",
|
||||
"03-21 10:19:47.000 3104 3456 I ActivityTaskManager: START u10 "
|
||||
"{cmp=ar.tvplayer.tv/.ui.MainActivity",
|
||||
" xflg=214 abi=arm64-v8a}",
|
||||
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
|
||||
]
|
||||
|
||||
|
||||
def test_logcat_start_records_parse_cmp():
|
||||
from nsh.ha_adb_common.logcat_events import foreground_from_logcat, foreground_packages
|
||||
|
||||
lines = _logcat_lines()
|
||||
assert foreground_packages(lines) == ["com.netflix.ninja", "ar.tvplayer.tv"]
|
||||
# LAST valid START wins
|
||||
assert foreground_from_logcat(lines) == "ar.tvplayer.tv"
|
||||
|
||||
|
||||
def test_logcat_non_start_and_null_ignored():
|
||||
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
|
||||
|
||||
assert foreground_from_logcat([
|
||||
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
|
||||
"plain noise",
|
||||
]) is None
|
||||
lines = _logcat_lines()
|
||||
assert foreground_from_logcat(lines) == "ar.tvplayer.tv" # null skipped
|
||||
|
||||
|
||||
def test_logcat_multi_user_prefix_tolerated():
|
||||
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
|
||||
|
||||
assert foreground_from_logcat(
|
||||
["03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u123 {cmp=net.whatsapp/"] + [
|
||||
" flg=0x1}"]
|
||||
) == "net.whatsapp"
|
||||
@@ -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,364 @@
|
||||
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
|
||||
|
||||
Fake client mirrors AndroidTVRemote's surface (callback registration,
|
||||
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
|
||||
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
|
||||
against the real library's VolumeInfo TypedDict when the package is present
|
||||
(`uvx --with androidtvremote2 pytest`).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from nsh.connection import (
|
||||
FIELD_AWAKE,
|
||||
FIELD_IS_AVAILABLE,
|
||||
FIELD_IS_ON,
|
||||
FIELD_SESSIONS,
|
||||
FIELD_VOLUME_INFO,
|
||||
ShieldConnectionManager,
|
||||
StateStore,
|
||||
volume_tuple,
|
||||
)
|
||||
|
||||
|
||||
class FakeProtocolClient:
|
||||
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
|
||||
|
||||
def __init__(self):
|
||||
self._is_on_callbacks: list = []
|
||||
self._is_available_callbacks: list = []
|
||||
self._current_app_callbacks: list = []
|
||||
self._volume_callbacks: list = []
|
||||
self._invalid_auth_callback = None
|
||||
self._reconnect_task = None
|
||||
self.disconnect_calls = 0
|
||||
|
||||
# -- registration (mirrors the real API) --
|
||||
def add_is_on_updated_callback(self, callback):
|
||||
self._is_on_callbacks.append(callback)
|
||||
|
||||
def add_is_available_updated_callback(self, callback):
|
||||
self._is_available_callbacks.append(callback)
|
||||
|
||||
def add_current_app_updated_callback(self, callback):
|
||||
self._current_app_callbacks.append(callback)
|
||||
|
||||
def add_volume_info_updated_callback(self, callback):
|
||||
self._volume_callbacks.append(callback)
|
||||
|
||||
# -- pushes (test helper: simulates device-side messages) --
|
||||
def push_is_on(self, on):
|
||||
for cb in list(self._is_on_callbacks):
|
||||
cb(on)
|
||||
|
||||
def push_is_available(self, available):
|
||||
for cb in list(self._is_available_callbacks):
|
||||
cb(available)
|
||||
|
||||
def push_current_app(self, app_package):
|
||||
for cb in list(self._current_app_callbacks):
|
||||
cb(app_package)
|
||||
|
||||
def push_volume_info(self, info):
|
||||
for cb in list(self._volume_callbacks):
|
||||
cb(info)
|
||||
|
||||
# -- lifecycle --
|
||||
def keep_reconnecting(self, invalid_auth_callback=None):
|
||||
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
|
||||
self._invalid_auth_callback = invalid_auth_callback
|
||||
loop = asyncio.get_event_loop()
|
||||
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self):
|
||||
while True: # the real task sleeps through its internal backoff
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
def fire_invalid_auth(self):
|
||||
self._invalid_auth_callback()
|
||||
|
||||
def disconnect(self):
|
||||
self.disconnect_calls += 1
|
||||
if self._reconnect_task is not None:
|
||||
self._reconnect_task.cancel()
|
||||
|
||||
|
||||
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
|
||||
|
||||
|
||||
async def _drain_loop():
|
||||
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
class TestPushCallbackWiring:
|
||||
async def test_is_on_and_availability_push_into_store(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
notified = []
|
||||
manager.register_listener(lambda: notified.append(True))
|
||||
|
||||
await manager._start_protocol()
|
||||
client.push_is_on(True)
|
||||
client.push_is_available(False)
|
||||
await _drain_loop()
|
||||
|
||||
assert manager.is_on is True
|
||||
assert manager.is_available is False
|
||||
assert manager.store.get(FIELD_IS_ON) is True
|
||||
assert notified, "listeners pinged from protocol pushes"
|
||||
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_current_app_push_stored_raw_package(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
client.push_current_app("com.netflix.ninja")
|
||||
await _drain_loop()
|
||||
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_volume_push_normalized_to_level_max_muted(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
client.push_volume_info({"level": 7, "max": 15, "muted": True})
|
||||
await _drain_loop()
|
||||
assert manager.volume_info == (7.0, 15.0, True)
|
||||
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestInvalidAuthHook:
|
||||
async def test_invalid_auth_invokes_entry_callback(self):
|
||||
client = FakeProtocolClient()
|
||||
fired = []
|
||||
manager = _protocol_manager(
|
||||
client, invalid_auth_callback=lambda: fired.append(True)
|
||||
)
|
||||
await manager._start_protocol()
|
||||
|
||||
client.fire_invalid_auth()
|
||||
|
||||
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_invalid_auth_without_callback_is_safe(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
client.fire_invalid_auth() # no callback registered — must not raise
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestLifecycle:
|
||||
async def test_stop_disconnects_lib_owned_reconnect_task(self):
|
||||
"""The library owns keep_reconnecting's task; closing the client
|
||||
tears it down — the manager never wraps that task itself."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
task = client._reconnect_task
|
||||
assert task is not None
|
||||
|
||||
await manager.async_stop()
|
||||
await _drain_loop() # let the lib-owned task settle its cancellation
|
||||
|
||||
assert client.disconnect_calls == 1
|
||||
assert task.cancelled()
|
||||
assert manager._protocol is None
|
||||
|
||||
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
built = []
|
||||
|
||||
def fake_builder(*args, **kwargs):
|
||||
built.append(kwargs)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=None,
|
||||
protocol_settings={
|
||||
"host": "shield.local",
|
||||
"client_name": "Home Assistant",
|
||||
"certfile": "/tmp/cert.pem",
|
||||
"keyfile": "/tmp/key.pem",
|
||||
},
|
||||
)
|
||||
await manager._start_protocol()
|
||||
|
||||
assert manager._protocol is client
|
||||
assert built
|
||||
kwargs = built[0]
|
||||
assert kwargs["host"] == "shield.local"
|
||||
assert kwargs["client_name"] == "Home Assistant"
|
||||
assert kwargs["certfile"] == "/tmp/cert.pem"
|
||||
assert kwargs["keyfile"] == "/tmp/key.pem"
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_passive_without_protocol_configuration(self):
|
||||
manager = _protocol_manager(client=None)
|
||||
await manager._start_protocol() # no factory, no settings → no-op
|
||||
|
||||
assert manager._protocol is None
|
||||
assert manager.is_on is None
|
||||
assert manager.is_available is None
|
||||
assert manager.current_app is None
|
||||
assert manager.volume_info is None
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestPushEdgeCases:
|
||||
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
|
||||
"""Push callbacks that outlive a closed loop must not crash."""
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
notified = []
|
||||
manager.register_listener(lambda: notified.append(True))
|
||||
|
||||
await manager._start_protocol()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.close()
|
||||
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
|
||||
|
||||
client.push_is_on(True)
|
||||
await _drain_loop()
|
||||
|
||||
assert manager.is_on is True # store stamped via direct-notify path
|
||||
assert notified # listeners still pinged synchronously
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_store_ttl_applies_to_protocol_fields(self):
|
||||
now = [1000.0]
|
||||
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client, store=store)
|
||||
await manager._start_protocol()
|
||||
client.push_is_on(True)
|
||||
await _drain_loop()
|
||||
|
||||
now[0] += 60.1
|
||||
assert manager.is_on is None # expired like every other store field
|
||||
assert manager.store.get(FIELD_IS_AVAILABLE) is None
|
||||
await manager.async_stop()
|
||||
|
||||
async def test_pushes_are_stamped_with_transition_time(self):
|
||||
client = FakeProtocolClient()
|
||||
manager = _protocol_manager(client)
|
||||
await manager._start_protocol()
|
||||
before = time.monotonic()
|
||||
client.push_is_available(True)
|
||||
await _drain_loop()
|
||||
after = time.monotonic()
|
||||
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
|
||||
assert before <= stamped_at <= after
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class FakeAdbForOutage:
|
||||
"""ADB poller stub for the outage test (wake poll fails)."""
|
||||
|
||||
def __init__(self):
|
||||
self.connected = True
|
||||
self.awake = None
|
||||
self.disconnect_calls = 0
|
||||
|
||||
async def connect(self):
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
self.disconnect_calls += 1
|
||||
self.connected = False
|
||||
|
||||
async def is_awake(self):
|
||||
return self.awake if self.connected else None
|
||||
|
||||
|
||||
class TestAdbOutageKeepsProtocolTruth:
|
||||
async def test_note_failure_clears_only_adb_fields(self):
|
||||
"""F2: an ADB outage must not clobber device-reported protocol truth."""
|
||||
poller = FakeAdbForOutage()
|
||||
poller.connected = True
|
||||
poller.awake = None # wake poll fails → cycle failure path
|
||||
client = FakeProtocolClient()
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, protocol=client
|
||||
)
|
||||
await manager._start_protocol()
|
||||
|
||||
client.push_is_on(True)
|
||||
client.push_is_available(True)
|
||||
client.push_current_app("com.netflix.ninja")
|
||||
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
||||
manager.store.set(FIELD_AWAKE, True)
|
||||
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
||||
|
||||
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
|
||||
|
||||
# ADB-derived fields cleared (regenerated by the watchdog)
|
||||
assert manager.store.get(FIELD_AWAKE) is None
|
||||
assert manager.store.get(FIELD_SESSIONS, []) == []
|
||||
# protocol push truth survives the ADB outage (TTL expiry rules it)
|
||||
assert manager.is_on is True
|
||||
assert manager.is_available is True
|
||||
assert manager.current_app == "com.netflix.ninja"
|
||||
assert manager.volume_info == (5.0, 15.0, False)
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestImportGuard:
|
||||
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
|
||||
"""F1: a protocol-enabled entry without androidtvremote2 keeps
|
||||
running ADB-only — the guarded factory logs a warning and builds
|
||||
nothing instead of raising at setup."""
|
||||
import logging
|
||||
|
||||
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
client = ShieldConnectionManager.build_protocol_client(
|
||||
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
|
||||
)
|
||||
assert client is None
|
||||
assert any("degrades to ADB-only" in r.message for r in caplog.records)
|
||||
|
||||
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=None,
|
||||
protocol_settings={
|
||||
"host": "shield.local",
|
||||
"client_name": "HA",
|
||||
"certfile": "/tmp/c.pem",
|
||||
"keyfile": "/tmp/k.pem",
|
||||
},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||
await manager._start_protocol()
|
||||
assert manager._protocol is None # passive, no crash
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestVolumeTuple:
|
||||
def test_normalizes_typed_dict(self):
|
||||
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
|
||||
|
||||
def test_defaults_on_partial_dict(self):
|
||||
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
|
||||
|
||||
def test_none_and_garbage_yield_none(self):
|
||||
assert volume_tuple(None) is None
|
||||
assert volume_tuple("garbage") is None
|
||||
|
||||
def test_accepts_real_library_volume_info(self):
|
||||
androidtvremote2 = pytest.importorskip("androidtvremote2")
|
||||
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
|
||||
assert volume_tuple(info) == (3.0, 15.0, True)
|
||||
@@ -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() == ""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for the running-apps list builder (recent tasks + media sessions)."""
|
||||
|
||||
from ha_adb_common.running_apps import running_apps_from_tasks
|
||||
|
||||
|
||||
def _session(package: str, playing: bool = False, title: str | None = None):
|
||||
from ha_adb_common.media_session import MediaSessionInfo
|
||||
|
||||
s = MediaSessionInfo(package=package)
|
||||
s.playing = playing
|
||||
s.paused = not playing
|
||||
s.title = title
|
||||
return s
|
||||
|
||||
|
||||
def test_returns_none_for_fewer_than_two_tasks():
|
||||
assert running_apps_from_tasks([]) is None
|
||||
assert running_apps_from_tasks([{"package": "app.a"}]) is None
|
||||
|
||||
|
||||
def test_marks_foreground_and_defaults_to_running():
|
||||
tasks = [{"package": "app.a"}, {"package": "app.b"}]
|
||||
apps = running_apps_from_tasks(tasks, [], foreground_package="app.b")
|
||||
assert apps is not None
|
||||
assert len(apps) == 2
|
||||
by_pkg = {a["package"]: a for a in apps}
|
||||
assert "foreground" not in by_pkg["app.a"]
|
||||
assert by_pkg["app.b"]["foreground"] is True
|
||||
assert by_pkg["app.a"]["state"] == "running"
|
||||
|
||||
|
||||
def test_enriches_with_media_session_state():
|
||||
tasks = [{"package": "app.a"}, {"package": "app.b"}]
|
||||
sessions = [_session("app.a", playing=True, title="Song")]
|
||||
apps = running_apps_from_tasks(tasks, sessions)
|
||||
assert apps is not None
|
||||
by_pkg = {a["package"]: a for a in apps}
|
||||
assert by_pkg["app.a"]["state"] == "playing"
|
||||
assert by_pkg["app.a"]["title"] == "Song"
|
||||
assert by_pkg["app.b"]["state"] == "running"
|
||||
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