Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab185ce50 | ||
|
|
400f0327af | ||
|
|
7ed982801a | ||
|
|
de1c34777d | ||
|
|
8f064b3247 | ||
|
|
6b866019ef | ||
|
|
66846b2f82 | ||
|
|
e6c6bdb1e6 | ||
|
|
259a6c29bd | ||
|
|
a8041e1428 | ||
|
|
2819ee5a49 | ||
|
|
91d9fb3883 | ||
|
|
14c168c25e | ||
|
|
6aa3b6bfdd | ||
|
|
8a4e72b80a | ||
|
|
7b32de789a | ||
|
|
f29f62ddae | ||
|
|
a1ce28996e | ||
|
|
46738063aa | ||
|
|
eb62011ded | ||
|
|
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 | ||
|
|
f829ef7a40 | ||
|
|
b706c031ea | ||
|
|
304e6dc3ec | ||
|
|
6622cc04b4 | ||
|
|
9da7cb8b22 | ||
|
|
37df5732b7 | ||
|
|
afc5f0a1d0 |
No files matched your search
@@ -0,0 +1,234 @@
|
||||
# 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, 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.
|
||||
#
|
||||
# No hand-made secrets are required: Gitea mints a token for every job and
|
||||
# exposes it as secrets.GITEA_TOKEN.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# Manual runs, for the first release (no tags yet) or to re-publish after a
|
||||
# partial failure.
|
||||
workflow_dispatch:
|
||||
|
||||
# One release at a time. Two pushes in quick succession would otherwise race to
|
||||
# tag the same commit; a later run dedupes safely instead of interleaving.
|
||||
concurrency:
|
||||
group: release-master
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# 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:
|
||||
# Tags drive versioning decisions, so the clone must carry them.
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Determine the release version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 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: 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"
|
||||
|
||||
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.RELEASE == 'yes'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd custom_components
|
||||
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
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 — 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
|
||||
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}"
|
||||
echo
|
||||
git log --pretty=format:'- %s' "$RANGE"
|
||||
} > /tmp/notes.md
|
||||
|
||||
# The body is built by an encoder rather than by string interpolation:
|
||||
# release notes are multi-line markdown and quoting them into JSON by
|
||||
# hand is a reliable way to publish a malformed release.
|
||||
python3 - <<'PY' > /tmp/release.json
|
||||
import json, os
|
||||
with open('/tmp/notes.md', encoding='utf-8') as handle:
|
||||
body = handle.read()
|
||||
print(json.dumps({
|
||||
'tag_name': 'v' + os.environ['VERSION'],
|
||||
'name': 'v' + os.environ['VERSION'],
|
||||
'body': body,
|
||||
'draft': False,
|
||||
'prerelease': False,
|
||||
}))
|
||||
PY
|
||||
|
||||
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
|
||||
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @/tmp/release.json)"
|
||||
|
||||
RELEASE_ID="$(jq -r '.id // empty' /tmp/release-response.json)"
|
||||
# 409 is "a release for this tag already exists": resolve the existing
|
||||
# release instead of failing, so the zip still gets attached.
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
RELEASE_ID="$(curl -sS "${SERVER}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||
-H "Authorization: token ${TOKEN}" | jq -r '.id')"
|
||||
else
|
||||
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
|
||||
echo "release creation failed (HTTP ${status})" >&2
|
||||
cat /tmp/release-response.json >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
case "$RELEASE_ID" in
|
||||
''|*[!0-9]*)
|
||||
echo "could not resolve a release id for ${TAG} (HTTP ${status})" >&2
|
||||
cat /tmp/release-response.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "release id: ${RELEASE_ID}"
|
||||
|
||||
curl -sS -o /tmp/asset-response.json \
|
||||
-X POST "${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${ZIP_NAME}"
|
||||
echo "published ${TAG} with ${ZIP_NAME}"
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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 tests are the ONLY place the integration meets the installed Home
|
||||
# Assistant — and EVERY one of them 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, 3 skipped" and still exits 0, and the drift sweep
|
||||
# never touches them. 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.
|
||||
#
|
||||
# t14 added a second file, and with it the reason this job GLOBS instead of
|
||||
# naming one: tests/test_adb_command_real_ha.py proves the adb_command action is
|
||||
# SELECTABLE (the runtime service description carries the entity target the
|
||||
# frontend builds its picker from — `async_register` has no target parameter) and
|
||||
# that a real async_call returns the command's output, and it also caught an
|
||||
# `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry
|
||||
# method. A named file list would have left it skipping silently here. The
|
||||
# pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up
|
||||
# by virtue of existing, and the files that ran are printed.
|
||||
#
|
||||
# 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 (or if the pattern matches nothing). 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 tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG=/tmp/real-ha.log
|
||||
# THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new
|
||||
# real-HA file cannot skip silently merely because nobody listed it here.
|
||||
# An empty match is a failure too — but it is checked first so the log says
|
||||
# why instead of blaming pytest's argument parsing.
|
||||
shopt -s nullglob
|
||||
FILES=(tests/test_*real_ha.py)
|
||||
shopt -u nullglob
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously"
|
||||
exit 1
|
||||
fi
|
||||
echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}"
|
||||
# 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 "${FILES[@]}" -q -rs 2>&1 | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::error::the real-HA 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 ("50 passed in 1.6s" / "1 skipped in
|
||||
# 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and
|
||||
# nothing else — a skip, a failure and a zero-collection run all fail
|
||||
# here, which is what makes the glob safe to widen.
|
||||
summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1)
|
||||
echo "pytest summary: $summary"
|
||||
case "$summary" in
|
||||
[0-9]*" passed"*) ;;
|
||||
*)
|
||||
echo "::error::the real-HA tests did not run (summary: $summary)"
|
||||
echo "These files skip themselves when homeassistant 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 run was not a clean pass (summary: $summary)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- custom_components/nvidia_shield/manifest.json
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read version from manifest.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(jq -r .version custom_components/nvidia_shield/manifest.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.tag }}" | grep -q .; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create tag
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin "${{ steps.version.outputs.tag }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check.outputs.exists == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ steps.version.outputs.tag }}" \
|
||||
--title "${{ steps.version.outputs.tag }}" \
|
||||
--generate-notes
|
||||
@@ -1,16 +1,218 @@
|
||||
"""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 (
|
||||
ADB_COMMAND_SERVICE_DESCRIPTION,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
CONF_REGISTERED_INTENTS,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SERVICE_ADB_COMMAND,
|
||||
SERVICE_SEND_INTENT,
|
||||
)
|
||||
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]
|
||||
# t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds
|
||||
# nothing unless the user opted in, so an install that never turns screen capture
|
||||
# on pays nothing for the platform existing (and toggling it is an options reload).
|
||||
PLATFORMS = [
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.SENSOR,
|
||||
Platform.REMOTE,
|
||||
Platform.BUTTON,
|
||||
Platform.IMAGE,
|
||||
]
|
||||
|
||||
|
||||
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.
|
||||
# The two APIs the target-bearing registration needs are imported WITH that
|
||||
# registration, next to the handler they belong to (t14): importing the package —
|
||||
# which every config-flow harness does via
|
||||
# ``custom_components.nvidia_shield.config_flow`` — must not drag in the service
|
||||
# registration surface for a domain that harness never sets up.
|
||||
from homeassistant.core import SupportsResponse
|
||||
from homeassistant.helpers.service import async_set_service_schema
|
||||
|
||||
from .media_player import (
|
||||
_ADB_COMMAND_SCHEMA,
|
||||
_INTENT_SCHEMA,
|
||||
_async_adb_command_service,
|
||||
_async_send_intent_service,
|
||||
send_intent_service_description,
|
||||
)
|
||||
|
||||
# t14: the action must be SELECTABLE for the user's Shield. Measured on HA
|
||||
# 2026.10.1: `async_register` has no `target` parameter, so the UI's target
|
||||
# picker comes from the service DESCRIPTION — the runtime
|
||||
# `async_set_service_schema` (which wins over a services.yaml we do not ship)
|
||||
# carries `target: {entity: [{domain: media_player}]}`, so the picker offers the
|
||||
# Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST
|
||||
# because async_set_service_schema reads `hass.services.supports_response()` to
|
||||
# publish the response flag, and the handler returns the command's output under
|
||||
# ADB_COMMAND_RESPONSE_KEY.
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADB_COMMAND,
|
||||
_async_adb_command_service,
|
||||
schema=_ADB_COMMAND_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
)
|
||||
|
||||
# t17: send_intent — the same measured mechanism as t14 (a runtime
|
||||
# description carries the target picker, and the response flag is read from
|
||||
# the registration, so OPTIONAL is registered FIRST). The description is
|
||||
# republished by every async_setup_entry because the intent DROPDOWN is
|
||||
# per-entry data while the service is domain-wide; at domain setup no entry
|
||||
# has loaded yet, so the list is empty and every name is still typeable
|
||||
# (`custom_value`), with the call-time lookup as the authority.
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_INTENT,
|
||||
_async_send_intent_service,
|
||||
schema=_INTENT_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(())
|
||||
)
|
||||
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"])
|
||||
# t17: the intents block travels VERBATIM to the import flow, which owns
|
||||
# the validation (and drops unusable rows with a log line) — the seed
|
||||
# normaliser here only decides which keys reach the flow at all.
|
||||
if isinstance(block.get(CONF_REGISTERED_INTENTS), list):
|
||||
seed[CONF_REGISTERED_INTENTS] = block[CONF_REGISTERED_INTENTS]
|
||||
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,23 +220,162 @@ 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))
|
||||
# t17: refresh the send_intent dropdown from what is ACTUALLY loaded. This
|
||||
# runs on every reload, and an options change reloads the entry, so adding
|
||||
# or removing an intent updates the picker. The union across entries is
|
||||
# deliberate: the service is domain-wide and any of them may be the target.
|
||||
_publish_send_intent_description(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _publish_send_intent_description(hass: HomeAssistant) -> None:
|
||||
"""Republish send_intent's description with the loaded intent names (t17).
|
||||
|
||||
Reads each loaded entry's OPTIONS through the runtime's own view of them
|
||||
(``registered_intents``), so a hand-edited or malformed row cannot reach the
|
||||
picker — the dropdown offers exactly what the action would accept.
|
||||
"""
|
||||
try:
|
||||
from homeassistant.helpers.service import async_set_service_schema
|
||||
|
||||
from .media_player import registered_intents, send_intent_service_description
|
||||
except ImportError: # pragma: no cover - a harness without HA's service module
|
||||
# The dropdown is a RENDERING nicety: the action itself is registered by
|
||||
# async_setup and works without it. Only a stub harness (which loads this
|
||||
# module without the real homeassistant package) can get here.
|
||||
return
|
||||
|
||||
names: list[str] = []
|
||||
for loaded in (hass.data.get(DOMAIN) or {}).values():
|
||||
options = getattr(loaded, "options", None)
|
||||
if options is None:
|
||||
options = getattr(getattr(loaded, "entry", None), "options", None)
|
||||
names.extend(intent["name"] for intent in registered_intents(options or {}))
|
||||
async_set_service_schema(
|
||||
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(names)
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
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,22 @@ 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
|
||||
from .media_player import (
|
||||
intent_default_variables,
|
||||
registered_intents,
|
||||
run_registered_intent,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,8 +30,18 @@ async def async_setup_entry(
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
entities: list[ButtonEntity] = []
|
||||
if entry.options.get(CONF_ADB_ENABLED, False):
|
||||
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)])
|
||||
entities.append(NvidiaShieldPairAdbButton(hass, entry))
|
||||
# t17: one button per REGISTERED intent. The registration IS the opt-in (the
|
||||
# plan: nothing registered ever runs by itself), so a new intent adds a
|
||||
# button and a removed one takes its button away — the options flow reloads
|
||||
# the entry on every save, which re-runs this setup.
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
for intent in registered_intents(entry.options):
|
||||
entities.append(NvidiaShieldIntentButton(hass, entry, manager, intent))
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
@@ -36,6 +49,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 +66,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 +81,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():
|
||||
@@ -100,3 +100,75 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
host,
|
||||
)
|
||||
await poller.disconnect()
|
||||
|
||||
|
||||
class NvidiaShieldIntentButton(ButtonEntity):
|
||||
"""One button per REGISTERED intent (t17).
|
||||
|
||||
The whole point for an intent with no variables: pressing it sends the intent
|
||||
with the fields' declared defaults. It deliberately does NOT cache a command
|
||||
— :func:`run_registered_intent` builds it every press, so the button, the
|
||||
action and the tests all exercise one code path.
|
||||
|
||||
Failures are LOUD. A missing required field, ADB being off/down, a transport
|
||||
that never answered and a device that refused the intent all raise with the
|
||||
reason (the device's own message included) — a button that silently did
|
||||
nothing is exactly the "silent success" the plan forbids. An app that
|
||||
rejected the intent is a CONFIGURATION problem, and the raised text says so.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, manager, intent: dict
|
||||
) -> None:
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._manager = manager
|
||||
self._intent = intent
|
||||
self._attr_name = intent["name"]
|
||||
# the intent id is stable across renames, so a rename does not orphan the
|
||||
# entity (and does not create a second one)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_intent_{intent['id']}"
|
||||
self._attr_icon = "mdi:gesture-tap-button"
|
||||
|
||||
@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 extra_state_attributes(self) -> dict:
|
||||
"""What this button will send — the registration, readable (t17).
|
||||
|
||||
A button whose registration the user cannot see is a black box: these are
|
||||
the SAME values the command is built from, and package/action/component
|
||||
are what they typed on the Intents screen.
|
||||
"""
|
||||
return {
|
||||
"intent_id": self._intent["id"],
|
||||
"package": self._intent["package"],
|
||||
"activity": self._intent.get("activity") or None,
|
||||
"action": self._intent.get("action") or None,
|
||||
"categories": list(self._intent.get("categories") or []),
|
||||
"data": self._intent.get("data") or None,
|
||||
"fields": [dict(field) for field in (self._intent.get("fields") or [])],
|
||||
}
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Send the intent with its declared defaults, or raise with the reason."""
|
||||
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
|
||||
variables = intent_default_variables(self._intent)
|
||||
output = await run_registered_intent(
|
||||
self._manager, entity_id, self._intent, variables
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"intent button %s sent %s → %s",
|
||||
entity_id,
|
||||
self._intent["id"],
|
||||
(output or "").strip().splitlines()[:1],
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
||||
# Re-export shared Media DAG constants
|
||||
from .ha_adb_common.const import ( # noqa: F401
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
@@ -12,6 +11,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 +28,457 @@ 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"
|
||||
#: t15: a PERSISTED marker that this entry's protocol client really paired (a
|
||||
#: successful ``async_finish_pairing``). Pairing cannot be inferred from anything
|
||||
#: else: the library exposes no ``is_paired``, and certificate material proves only
|
||||
#: that SOMETHING generated a key — which an entry that merely enabled the transport
|
||||
#: without pairing has too. Written by the setup flow's pairing step, the options
|
||||
#: flow's, and the reauth/reconfigure finishers, so enabling the transport later can
|
||||
#: tell "already paired" (reuse the certificate, no new PIN) from "never paired"
|
||||
#: (must pair now, or stay off).
|
||||
CONF_PROTOCOL_PAIRED = "protocol_paired"
|
||||
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
|
||||
|
||||
# t15: the remote-protocol transport's own state machine. The library offers NO
|
||||
# ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the
|
||||
# states we can actually prove — and the one the user was reading as "maybe
|
||||
# unpaired" was ``never_connected``: keep_reconnecting() alone never connects
|
||||
# (its loop is ``while self._remote_message_protocol:``, None until a first
|
||||
# ``async_connect()``), so the transport had never been switched on at all.
|
||||
PROTOCOL_OFF = "off" # no client wired (disabled/no library)
|
||||
PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection
|
||||
PROTOCOL_CONNECTED = "connected" # device reachable at the protocol
|
||||
PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down
|
||||
PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth)
|
||||
|
||||
#: Seconds allowed for the FIRST protocol connect before it counts as unreachable.
|
||||
#: Core androidtv_remote uses the same 5 s before deciding ready/not-ready.
|
||||
PROTOCOL_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
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:"
|
||||
|
||||
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
|
||||
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
|
||||
# procstats + usagestats + batterystats), and the old fetch shipped
|
||||
# ``| head -300`` — which cut before the package sections even appeared, so the
|
||||
# negative was a truncation artefact rather than an answer. Searching on-device
|
||||
# keeps the wire small AND the answer honest: these are the three shapes Android
|
||||
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
|
||||
# name in each).
|
||||
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
|
||||
|
||||
# t66: how many label probes may come back "ran, no label" IN A ROW before the
|
||||
# device is treated as one that exposes no labels at all (this user's Shield).
|
||||
# One label-less package is ordinary (system/OEM packages often have none), so a
|
||||
# single miss proves nothing — but a run of them means the whole firmware keeps
|
||||
# its labels in resources we cannot read, and every further ``pm dump`` per
|
||||
# package is a shell spent on a question already answered. The conclusion expires
|
||||
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
|
||||
# found anywhere resets the run.
|
||||
APP_LABEL_PROBE_LIMIT = 5
|
||||
|
||||
# P4-2a adb_command service: raw ADB 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"
|
||||
|
||||
#: t14: the key the command's output is returned under, in the service RESPONSE.
|
||||
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
|
||||
#: template written against that integration keeps reading the same field.
|
||||
ADB_COMMAND_RESPONSE_KEY = "adb_response"
|
||||
|
||||
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
|
||||
#:
|
||||
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
|
||||
#: parameter, so a target cannot be declared at registration. The frontend renders
|
||||
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
|
||||
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
|
||||
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
|
||||
#: selected as its ``media_player`` entity (one per entry), exactly like the
|
||||
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
|
||||
#:
|
||||
#: The entity domain the action's target declares, and the only domain a
|
||||
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
|
||||
#: image entity — none of those is something to run a raw shell command on).
|
||||
ADB_COMMAND_TARGET_DOMAIN = "media_player"
|
||||
|
||||
ADB_COMMAND_SERVICE_TARGET: dict = {
|
||||
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
|
||||
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
|
||||
#:
|
||||
#: Why a runtime description at all: the description is what the frontend receives
|
||||
#: from ``get_services``, and a service with no description has no ``target``, which
|
||||
#: is precisely why the user's UI offered no Shield to select and he had to run his
|
||||
#: diagnostics through the deprecated integration. The prose here mirrors
|
||||
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
|
||||
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
|
||||
#: asserts the two cannot drift.
|
||||
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
|
||||
"name": "ADB command",
|
||||
"description": (
|
||||
"Send a raw ADB shell command to the persistent connection of an Nvidia "
|
||||
"Shield entry (option-gated use)."
|
||||
),
|
||||
"target": ADB_COMMAND_SERVICE_TARGET,
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
"description": "The shell command to run on the device.",
|
||||
"required": True,
|
||||
"selector": {"text": {}},
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": (
|
||||
"The Shield to run it on: its media_player entity. Select it in "
|
||||
"the target picker above, or pass entity_id (or the Shield's "
|
||||
"device_id) in YAML."
|
||||
),
|
||||
"selector": {"entity": {"domain": "media_player"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# ── t17: registered intents (the second half of the intents feature) ──
|
||||
#
|
||||
# A registered intent is ordinary OPTIONS data: a list of dicts, one per intent,
|
||||
# stored under CONF_REGISTERED_INTENTS in JSON-safe form (no sets, no objects).
|
||||
# It is read by three consumers that must agree about the shape: the options
|
||||
# screen (which writes it), the button platform (one entity per row) and the
|
||||
# nvidia_shield.send_intent action (which builds the command). The shape is the
|
||||
# one docs/INTENTS_PLAN.md §3 fixes:
|
||||
#
|
||||
# {id, name, package, activity, action, categories, data, fields}
|
||||
#
|
||||
# ``activity`` and ``action`` are alternatives (the plan: a component OR an
|
||||
# action, optionally with categories), and ``fields`` declares the variables the
|
||||
# caller may (or must) supply. Only the DATA template carries {placeholders}.
|
||||
CONF_REGISTERED_INTENTS = "registered_intents"
|
||||
|
||||
#: The action that fires a registered intent. Named by the feature, not by the
|
||||
#: mechanism: the device's own vocabulary is "am start", which the user never
|
||||
#: types.
|
||||
SERVICE_SEND_INTENT = "send_intent"
|
||||
|
||||
#: t17: the key the device's `am start` output is returned under. Deliberately
|
||||
#: NOT ``adb_response``: that key belongs to the raw-command action, and a
|
||||
#: template reading it should not silently start seeing intent output.
|
||||
INTENT_RESPONSE_KEY = "intent_response"
|
||||
|
||||
#: The four field types the plan fixes (v1): string/int/bool map to the device's
|
||||
#: own flags (``--es``/``--ei``/``--ez``), and ``raw`` passes an extra through
|
||||
#: verbatim — the documented escape hatch, NOT the default (the plan's §9
|
||||
#: non-goal is that nothing beyond these four is expressible).
|
||||
INTENT_FIELD_TYPES = ("string", "int", "bool", "raw")
|
||||
|
||||
#: The flag each declared type becomes on ``am start``.
|
||||
INTENT_TYPE_FLAGS = {
|
||||
"string": "--es",
|
||||
"int": "--ei",
|
||||
"bool": "--ez",
|
||||
"raw": "--es",
|
||||
}
|
||||
|
||||
#: The explicit "Add new" sentinel of the intents selector. A registered intent
|
||||
#: id can never collide with it: ids must be slug-shaped (letters, digits,
|
||||
#: underscore), and this one is not.
|
||||
INTENT_NEW_SENTINEL = "__new__"
|
||||
|
||||
#: The options-flow fields of the per-intent form (one row at a time, the same
|
||||
#: idiom as the apps editor). The declared fields are edited as TEXT, one
|
||||
#: ``key:type[:required][=default]`` per line, because that is the shape the user
|
||||
#: already knows from the apps table and it survives a paste.
|
||||
INTENT_FIELD_CHOICE = "intent_choice"
|
||||
INTENT_FIELD_NAME = "intent_name"
|
||||
INTENT_FIELD_PACKAGE = "intent_package"
|
||||
INTENT_FIELD_ACTIVITY = "intent_activity"
|
||||
INTENT_FIELD_ACTION = "intent_action"
|
||||
INTENT_FIELD_CATEGORIES = "intent_categories"
|
||||
INTENT_FIELD_DATA = "intent_data"
|
||||
INTENT_FIELD_DECLARED = "intent_fields"
|
||||
INTENT_FIELD_CANDIDATE = "intent_candidate"
|
||||
INTENT_FIELD_SCAN_PACKAGE = "intent_scan_package"
|
||||
|
||||
#: How many declared fields one intent may carry, and how many intents an entry
|
||||
#: may register. Bounded so a hand-edited options blob (or a runaway paste)
|
||||
#: cannot turn one button press into a command with thousands of flags, and so
|
||||
#: the options page stays readable.
|
||||
INTENT_FIELD_LIMIT = 20
|
||||
INTENT_REGISTRATION_LIMIT = 50
|
||||
|
||||
#: The same target picker as adb_command (t14's measured mechanism), reused
|
||||
#: verbatim: an intent is sent to exactly ONE Shield, selected as its media_player
|
||||
#: entity, and the runtime description is what renders the picker.
|
||||
INTENT_SERVICE_TARGET: dict = {
|
||||
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
|
||||
# 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/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
|
||||
# directory fallback and the third tier of the app-name chain (after the user's
|
||||
# own map and a device label, before the raw package id) — on a Shield whose
|
||||
# `pm dump` carries no label at all it is the answer that makes names work.
|
||||
#
|
||||
# It is a community-maintained convenience, NOT device truth: package ids are
|
||||
# stable, display names change with vendors and app updates, and an app this
|
||||
# table has never heard of falls back to its package id (shown once). Users
|
||||
# override any entry in Configure > Apps, and that edit always wins. Adding a row
|
||||
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
|
||||
DEFAULT_APP_DIRECTORY = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
|
||||
"com.amazon.amazonvideo.livingroom": "Prime Video",
|
||||
"com.esaba.downloader": "Downloader",
|
||||
"com.github.damontecres.wholphin": "Wholphin",
|
||||
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
|
||||
"com.google.android.speech.pumpkin": "Google Speech Services",
|
||||
"com.google.android.tts": "Speech Services by Google",
|
||||
"com.google.android.tvlauncher": "Google TV launcher",
|
||||
"com.google.android.youtube.tv": "YouTube",
|
||||
"com.hyperion.grabber": "Hyperion Grabber",
|
||||
"com.lonelycatgames.Xplore": "X-plore File Manager",
|
||||
"com.nendo.argosy": "Argosy",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.plexapp.mediaserver.smb": "Plex Media Server",
|
||||
"com.retroarch": "RetroArch",
|
||||
"com.spocky.projengmenu": "Projectivy Launcher",
|
||||
"dev.jdtech.jellyfin": "Findroid",
|
||||
"dev.trooped.tvquickbars": "TV Quick Actions",
|
||||
"flar2.homebutton": "Button Mapper",
|
||||
"tv.pluto.android": "Pluto TV",
|
||||
}
|
||||
|
||||
# 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"
|
||||
|
||||
# t66: the screen-capture image entity — OFF by default. A capture is a
|
||||
# full-resolution screen grab, and the user's standing constraint is that screen
|
||||
# capture must never drive state; opt-in keeps a diagnostic surface from costing
|
||||
# every install a grab per dashboard view.
|
||||
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
|
||||
|
||||
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,31 +1,110 @@
|
||||
"""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_app_label,
|
||||
parse_application_label,
|
||||
parse_label_res,
|
||||
parse_non_localized_label,
|
||||
resolve_package_name,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
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 .intents import (
|
||||
INTENT_CANDIDATE_LIMIT,
|
||||
INTENT_ENTRY_CANDIDATES,
|
||||
INTENT_ENTRY_EMPTY,
|
||||
INTENT_SCAN_ADB_DISABLED,
|
||||
INTENT_SCAN_ADB_UNAVAILABLE,
|
||||
INTENT_SCAN_CACHED,
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
INTENT_SCAN_INVALID_PACKAGE,
|
||||
INTENT_SCAN_TTL,
|
||||
INTENT_SCAN_UNREADABLE,
|
||||
INTERNAL_MARKERS,
|
||||
IntentCandidate,
|
||||
IntentScanCache,
|
||||
IntentScanResult,
|
||||
activity_resolver_section,
|
||||
has_activity_resolver_table,
|
||||
intent_scan_command,
|
||||
intent_scan_fallback_command,
|
||||
is_likely_internal,
|
||||
parse_activity_resolver_table,
|
||||
parse_intent_report,
|
||||
)
|
||||
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__ = [
|
||||
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
|
||||
"ATTR_MEDIA_DAG_NOW_PLAYING",
|
||||
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
|
||||
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
|
||||
"ATTR_MEDIA_DAG_ROLE",
|
||||
"ATTR_MEDIA_DAG_RUNNING_APPS",
|
||||
"AdbClientBase",
|
||||
"INTENT_CANDIDATE_LIMIT",
|
||||
"INTENT_ENTRY_CANDIDATES",
|
||||
"INTENT_ENTRY_EMPTY",
|
||||
"INTENT_SCAN_ADB_DISABLED",
|
||||
"INTENT_SCAN_ADB_UNAVAILABLE",
|
||||
"INTENT_SCAN_CACHED",
|
||||
"INTENT_SCAN_FRESH",
|
||||
"INTENT_SCAN_FRESH_EMPTY",
|
||||
"INTENT_SCAN_INVALID_PACKAGE",
|
||||
"INTENT_SCAN_TTL",
|
||||
"INTENT_SCAN_UNREADABLE",
|
||||
"INTERNAL_MARKERS",
|
||||
"IntentCandidate",
|
||||
"IntentScanCache",
|
||||
"IntentScanResult",
|
||||
"MediaSessionInfo",
|
||||
"PlaybackIntent",
|
||||
"activity_resolver_section",
|
||||
"has_activity_resolver_table",
|
||||
"intent_scan_command",
|
||||
"intent_scan_fallback_command",
|
||||
"is_likely_internal",
|
||||
"parse_activity_resolver_table",
|
||||
"parse_intent_report",
|
||||
"ensure_adb_key",
|
||||
"parse_all_media_sessions",
|
||||
"parse_app_label",
|
||||
"parse_application_label",
|
||||
"parse_label_res",
|
||||
"parse_non_localized_label",
|
||||
"resolve_package_name",
|
||||
"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,151 @@
|
||||
"""App friendly-name resolution from the package dump (P5-1, t66).
|
||||
|
||||
Parses the label candidate LINES of a `pm dump <package>` / `dumpsys package`
|
||||
answer. Three shapes are searched, in priority order, because Android firmware
|
||||
prints different ones (and one of them on this user's Shield prints nothing at
|
||||
all):
|
||||
|
||||
1. ``application-label: 'Netflix'`` — the AAPT-style manifest label, with its
|
||||
localized suffixed variants (``application-label-<lang>:``) as a fallback.
|
||||
2. ``nonLocalizedLabel=Netflix`` — the ApplicationInfo field. It is usually
|
||||
``null`` on a TV device (the label comes from a resource), so a null/empty
|
||||
value is not a name.
|
||||
3. ``labelRes=0x7f0f0001`` — a RESOURCE ID, not text. A dump can only name the
|
||||
app here if it prints a quoted string, so a numeric/hex id is deliberately
|
||||
NOT used as a name: we would be showing ``0x7f0f0001`` as if it were a name.
|
||||
|
||||
Nothing here is a guess about which line exists: every branch is anchored on the
|
||||
literal prefix Android prints, and a miss returns None so the caller falls
|
||||
through to the curated map (the answer on a device with no labels at all).
|
||||
|
||||
Also exports the ONE package -> name precedence chain, so the media player, the
|
||||
Current App sensor, the discovery table and the options editor cannot drift:
|
||||
user map > device label > curated map > raw package id.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
#: ``application-label: 'X'`` / ``application-label-de: "X"`` — the AAPT form.
|
||||
_APPLICATION_LABEL_RE = re.compile(
|
||||
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||
)
|
||||
|
||||
#: ``nonLocalizedLabel=X`` — bare word, quoted string, or ``null``.
|
||||
_NON_LOCALIZED_LABEL_RE = re.compile(
|
||||
r"nonLocalizedLabel=(?:'([^']*)'|\"([^\"]*)\"|([^\s,}]+))"
|
||||
)
|
||||
|
||||
#: ``labelRes=0x7f0f0001`` — only a QUOTED value is text; an id is not a name.
|
||||
_LABEL_RES_RE = re.compile(r"labelRes=(?:'([^']*)'|\"([^\"]*)\")")
|
||||
|
||||
#: Values a dump prints that are not names.
|
||||
_NOT_A_NAME = ("", "null", "0x0", "0")
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str | None:
|
||||
"""A candidate label, or None when it is empty/``null``."""
|
||||
text = (value or "").strip()
|
||||
if text.lower() in _NOT_A_NAME:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
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 = _clean(match.group(1) or match.group(2))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_non_localized_label(dump: str) -> str | None:
|
||||
"""The ``nonLocalizedLabel=`` value, or None (t66).
|
||||
|
||||
``null`` is the common case on a TV and is NOT a name.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _NON_LOCALIZED_LABEL_RE.finditer(dump):
|
||||
label = _clean(match.group(1) or match.group(2) or match.group(3))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_label_res(dump: str) -> str | None:
|
||||
"""A QUOTED ``labelRes=`` value, or None (t66).
|
||||
|
||||
``labelRes=0x7f0f0001`` is a resource id — showing it as an app name would be
|
||||
worse than showing the package id, so only a quoted string counts.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _LABEL_RES_RE.finditer(dump):
|
||||
label = _clean(match.group(1) or match.group(2))
|
||||
if label:
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
def parse_app_label(dump: str) -> str | None:
|
||||
"""The best device label in ``dump``, trying every shape Android prints.
|
||||
|
||||
Priority follows how specific the shape is: the AAPT manifest label first,
|
||||
then the ApplicationInfo field, then a quoted resource label. None means the
|
||||
device did not name this app — which, on a device whose dump carries no
|
||||
labels at all, is the definitive answer that sends the caller to the curated
|
||||
map (t66).
|
||||
"""
|
||||
return (
|
||||
parse_application_label(dump)
|
||||
or parse_non_localized_label(dump)
|
||||
or parse_label_res(dump)
|
||||
)
|
||||
|
||||
|
||||
def resolve_package_name(
|
||||
package: str | None,
|
||||
*,
|
||||
user_map=None,
|
||||
device_label=None,
|
||||
curated=None,
|
||||
) -> str | None:
|
||||
"""THE package -> display-name chain (t66): one order, every surface.
|
||||
|
||||
1. the user's explicit apps map (their own edit always wins);
|
||||
2. the device's own label, when the device exposes one;
|
||||
3. the curated community map (the answer on a device with no labels: see
|
||||
``DEFAULT_APP_DIRECTORY``);
|
||||
4. the raw package id, shown ONCE.
|
||||
|
||||
``device_label`` may be a VALUE or a CALLABLE. A callable is looked up only
|
||||
after the user map has missed, which is what keeps tier 2 LAZY: the manager's
|
||||
``app_label()`` accessor enqueues an on-demand device fetch on a miss, so
|
||||
calling it for an app the user has already named would spend a shell to learn
|
||||
something nobody reads.
|
||||
|
||||
Returns None only for an empty package, so a caller can always display
|
||||
something. The id is never doubled into a ``pkg=pkg`` row: tier 4 IS the id,
|
||||
and there is no tier that formats it as a name as well.
|
||||
"""
|
||||
if not package:
|
||||
return None
|
||||
mapped = (user_map or {}).get(package)
|
||||
if mapped:
|
||||
return str(mapped)
|
||||
raw_label = device_label(package) if callable(device_label) else device_label
|
||||
label = _clean(raw_label)
|
||||
if label:
|
||||
return label
|
||||
curated_name = (curated or {}).get(package)
|
||||
if curated_name:
|
||||
return str(curated_name)
|
||||
return package
|
||||
@@ -17,9 +17,11 @@ ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
|
||||
# Jellyfin media_player entity for rich metadata
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
|
||||
|
||||
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
|
||||
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
|
||||
# t65 flattened the now-playing reading into separate top-level ``now_playing_*``
|
||||
# attributes on the media player (``now_playing_via`` names the tier), so the single
|
||||
# nested ``media_dag_now_playing`` key this file used to define is GONE — the last
|
||||
# reference to it was removed in t66's cleanup. The flattened names live in
|
||||
# ``custom_components/nvidia_shield/media_player.py`` (``now_playing_attribute_names``).
|
||||
|
||||
# List of running app dicts: [{package, state, title?, artist?}, ...]
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
|
||||
@@ -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,705 @@
|
||||
"""Intent discovery from an app's Activity Resolver Table (t16).
|
||||
|
||||
The approved intents feature (``docs/INTENTS_PLAN.md`` §2) starts here: the user
|
||||
scans ONE installed app and we answer with the intents that app advertises. The
|
||||
source of truth is the ``Activity Resolver Table:`` section Android prints in
|
||||
``pm dump <package>``.
|
||||
|
||||
WHY THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield (captured
|
||||
2026-10-12) ``pm dump com.netflix.ninja`` is ~2241 lines of WHOLE dumpsys —
|
||||
``DUMP OF SERVICE package:`` and then activity/meminfo/procstats/usagestats/
|
||||
batterystats — for ONE app. Shipping that over ADB to read a few dozen resolver
|
||||
lines is the same mistake ``| head -300`` made for app labels (t66, where the
|
||||
truncation was misread as "this app has no label"), so the command built here
|
||||
pipes the dump through ``sed`` ON THE DEVICE and only the resolver section
|
||||
crosses the wire:
|
||||
|
||||
pm dump <pkg> 2>/dev/null \
|
||||
| sed -n "/Activity Resolver Table/,/Service Resolver Table/p" \
|
||||
| head -n <cap>
|
||||
|
||||
with a bounded ``grep -A … -m 1 -F`` fallback (``intent_scan_fallback_command``)
|
||||
for a device whose toolbox cannot do sed ranges. The package id is always
|
||||
``shlex.quote``d — a package name is the only thing in either command that could
|
||||
carry shell metacharacters, and quoting it is what makes the command safe — and
|
||||
everything else in the command is a fixed constant from this file.
|
||||
|
||||
WHAT THE CANDIDATES CARRY AND WHY. Per §3's data model: component, action,
|
||||
categories, and the data (scheme, authority, path). A filter that carries several
|
||||
schemes, authorities or paths is expanded into ONE CANDIDATE PER COMBINATION,
|
||||
because each of those is a concrete match — a URI the app really accepts — and a
|
||||
picker row that mixes two schemes cannot be turned into a URI at all. Candidates
|
||||
are deduplicated on the whole tuple, so the expansion costs nothing when the
|
||||
app's filters coincide.
|
||||
|
||||
WHAT THIS MODULE DOES NOT MODEL: MIME types. The resolver key of a MIME filter
|
||||
(``video/*:``) is not one of §3's fields, and inventing a data URI out of it
|
||||
would be worse than reporting the filter through its component, action and
|
||||
categories — which is what happens. Nothing is dropped for being unmodelled.
|
||||
|
||||
MARK — READ THIS BEFORE TRUSTING A SHAPE. The parser is proven against a
|
||||
TRANSCRIBED capture (``tests/fixtures/pm_dump_netflix_resolver.txt``), not
|
||||
against a device: no real Shield was available, and the raw capture text is not
|
||||
in this repository either. The entry/detail shapes below are what the capture
|
||||
records (see the fixture's own notes for which lines are verbatim and which are
|
||||
reconstructed), and every parser branch is deliberately tolerant of a shape we
|
||||
have never seen: an unrecognised line degrades to "no candidate from this line",
|
||||
never to a wrong candidate.
|
||||
|
||||
NOT TO BE CONFUSED WITH ``.intent``: that module holds ``PlaybackIntent``, the
|
||||
optimistic-UI dataclass, not an Android intent. §7.4 of the plan suggests renaming
|
||||
it to ``playback_intent.py``; that churn is not part of this unit, so the two names
|
||||
sit side by side here and this note is the signpost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
#: ``Activity Resolver Table:`` — the section that opens the package's resolver
|
||||
#: tables, and the two headings that END it on the observed capture (the receiver
|
||||
#: table follows the activity one, then the service table).
|
||||
ACTIVITY_TABLE_HEADER = "Activity Resolver Table:"
|
||||
RECEIVER_TABLE_HEADER = "Receiver Resolver Table:"
|
||||
SERVICE_TABLE_HEADER = "Service Resolver Table:"
|
||||
PROVIDER_TABLE_HEADER = "Provider Resolver Table:"
|
||||
|
||||
#: The end of the activity section: whichever of these comes FIRST. Stopping at
|
||||
#: the first one can only ever cut the section short, never extend it into
|
||||
#: another table (the plan's sed uses the service heading; a capture where the
|
||||
#: receiver table is absent still ends here).
|
||||
_SECTION_ENDS = (
|
||||
RECEIVER_TABLE_HEADER,
|
||||
SERVICE_TABLE_HEADER,
|
||||
PROVIDER_TABLE_HEADER,
|
||||
)
|
||||
|
||||
#: The two patterns the ON-DEVICE sed range is built from. The colons are dropped
|
||||
#: deliberately: the shipped command is the plan's own line
|
||||
#: (``sed -n '/Activity Resolver Table/,/Service Resolver Table/p'``), which is
|
||||
#: what has to be recognisable in a shell and in a review, and both patterns match
|
||||
#: the same printed heading. They are constants — never caller text — so quoting
|
||||
#: them in the command below cannot be broken from outside.
|
||||
SED_RANGE_START = "Activity Resolver Table"
|
||||
SED_RANGE_END = "Service Resolver Table"
|
||||
|
||||
#: How long a scan that RAN is reused, per package. An app's advertised intents
|
||||
#: only change when the app does, so this is a cost bound rather than a staleness
|
||||
#: guess: one shell an hour for a package the user keeps looking at.
|
||||
INTENT_SCAN_TTL = 3600.0
|
||||
|
||||
#: The transfer bound of both commands (``head -n`` / ``grep -A``). A real
|
||||
#: resolver section is tens of lines; this is what keeps a pathological table
|
||||
#: from becoming a wall of text on the wire.
|
||||
INTENT_SCAN_LINE_LIMIT = 400
|
||||
|
||||
#: The cap on returned candidates, applied AFTER dedup and ranking. The cap is
|
||||
#: disclosed (``IntentParseResult.truncated`` / ``IntentScanResult.truncated``),
|
||||
#: so a capped answer never looks complete.
|
||||
INTENT_CANDIDATE_LIMIT = 200
|
||||
|
||||
#: One entry line: an optional group key, the component's hex id, the COMPONENT
|
||||
#: and — for an activity filter — the filter's own id. Both shapes the capture
|
||||
#: records match, with or without the group key in front:
|
||||
#:
|
||||
#: cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
#: deca7fa com.netflix.ninja/.MainActivity filter fc483a2
|
||||
_ENTRY_RE = re.compile(
|
||||
r"^(?P<key>.*?)\s*(?P<hex_id>[0-9a-fA-F]{6,})\s+"
|
||||
r"(?P<component>[A-Za-z_][\w.]*/[\w.$]+)"
|
||||
r"(?:\s+filter\s+(?P<filter_id>[0-9a-fA-F]+))?\s*$"
|
||||
)
|
||||
|
||||
#: A group heading on its own line (``Schemes:``, ``Non-Data Actions:``, and the
|
||||
#: action-keyed groups inside it). Used for ONE fallback only: when a filter
|
||||
#: carries no ``Action:`` detail, the key that grouped it is the action the
|
||||
#: device printed — never a guess about a key that is not action-shaped.
|
||||
_GROUP_KEY_RE = re.compile(r"^\s+(?P<key>[A-Za-z_][\w.$]*)\s*:\s*$")
|
||||
|
||||
#: A filter detail line. The five fields §3's model carries; ``Type:`` (a MIME
|
||||
#: type) and anything else are read off the line and ignored — see the module
|
||||
#: docstring on what is not modelled.
|
||||
_DETAIL_RE = re.compile(
|
||||
r"^\s*(?P<field>Action|Category|Scheme|Authority|Path)\s*:\s*(?P<value>.+?)\s*$"
|
||||
)
|
||||
|
||||
#: ``Authority: "www.netflix.com": -1`` — the authority, not its weight.
|
||||
_AUTHORITY_RE = re.compile(r'^"(?P<value>[^"]*)"\s*(?::\s*-?\d+)?$')
|
||||
|
||||
#: ``Path: "PatternMatcher{GLOB: /title.*}"`` — the matcher kind and its value.
|
||||
#: Both separators Android prints are accepted (``GLOB: /x`` and ``LITERAL, /x``).
|
||||
_PATH_MATCHER_RE = re.compile(
|
||||
r"^PatternMatcher\{(?P<kind>[A-Za-z_]+)\s*[:,]?\s*(?P<value>.*)\}$"
|
||||
)
|
||||
|
||||
#: The marker families ``docs/INTENTS_PLAN.md`` §2 names as internal-looking.
|
||||
#: They are FLAGGED and never deleted: the user decides what is worth
|
||||
#: registering, and a candidate we quietly removed is one they can never
|
||||
#: register. Matching is on the action and the component (the capture's own
|
||||
#: noise is action-shaped, e.g. ``com.netflix.action.DEBUG_PUSH_NOTIFICATION``).
|
||||
INTERNAL_MARKERS = (
|
||||
"DEBUG_",
|
||||
"androidx.",
|
||||
"com.google.firebase.",
|
||||
"ConstraintProxy",
|
||||
)
|
||||
|
||||
#: A cache entry's state (t16). ONLY a scan that RAN writes an entry, and the
|
||||
#: entry then says which of these two answers it holds — the distinction the
|
||||
#: discovery-cache defect taught us, made structural instead of remembered.
|
||||
INTENT_ENTRY_CANDIDATES = "candidates"
|
||||
INTENT_ENTRY_EMPTY = "empty"
|
||||
|
||||
#: A scan result's status. ``answered`` is True for the first three only: the
|
||||
#: rest mean "we do not know", which is NOT the same as "this app advertises
|
||||
#: nothing" and must never be shown as an empty list of intents.
|
||||
INTENT_SCAN_FRESH = "fresh"
|
||||
INTENT_SCAN_FRESH_EMPTY = "fresh_empty"
|
||||
INTENT_SCAN_CACHED = "cached"
|
||||
INTENT_SCAN_ADB_DISABLED = "adb_disabled"
|
||||
INTENT_SCAN_ADB_UNAVAILABLE = "adb_unavailable"
|
||||
INTENT_SCAN_UNREADABLE = "unreadable"
|
||||
INTENT_SCAN_INVALID_PACKAGE = "invalid_package"
|
||||
|
||||
#: The statuses whose candidates are an ANSWER from the device.
|
||||
_ANSWERED_STATUSES = (
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
INTENT_SCAN_CACHED,
|
||||
)
|
||||
|
||||
|
||||
def _unquote(value: str | None) -> str:
|
||||
"""Strip one layer of matching quotes; DROP an unterminated one (t21/F2).
|
||||
|
||||
``"x"`` -> ``x``. A value that OPENS a quote and never closes it is not a
|
||||
value we know: the capture was cut mid-detail (``Scheme: "ht``,
|
||||
``Authority: "www.exam``, ``Path: "PatternMatcher{GLOB: /title``), and
|
||||
believing the leftover produced a scheme, host or pattern that LOOKS real
|
||||
while no app can match it — `_candidate_data` would then prefill the form
|
||||
with a URL that cannot work. An unterminated value is therefore dropped
|
||||
(empty here; the callers turn that into None), so a corrupt detail is never
|
||||
mistaken for a real one. It was never an injection either way (every value is
|
||||
quoted when it is used), which is exactly why the failure was invisible.
|
||||
"""
|
||||
text = (value or "").strip()
|
||||
if text[:1] in ("'", '"'):
|
||||
if len(text) >= 2 and text[-1] == text[0]:
|
||||
return text[1:-1].strip()
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _authority_value(raw: str) -> str | None:
|
||||
"""The authority in an ``Authority:`` detail, or None.
|
||||
|
||||
Android prints ``Authority: "www.netflix.com": -1`` (quoted, then the filter's
|
||||
score); a bare ``Authority: www.netflix.com`` is accepted too.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
match = _AUTHORITY_RE.match(text)
|
||||
if match:
|
||||
return match.group("value") or None
|
||||
return _unquote(text) or None
|
||||
|
||||
|
||||
def _path_value(raw: str) -> tuple[str | None, str | None]:
|
||||
"""``(matcher_kind, path)`` from a ``Path:`` detail.
|
||||
|
||||
``"PatternMatcher{GLOB: /title.*}"`` -> ``("GLOB", "/title.*")``;
|
||||
``"PatternMatcher{LITERAL, /browse}"`` -> ``("LITERAL", "/browse")``; anything
|
||||
that is not a PatternMatcher is returned as a bare path with no kind.
|
||||
"""
|
||||
text = _unquote(raw)
|
||||
match = _PATH_MATCHER_RE.match(text)
|
||||
if match:
|
||||
return match.group("kind").upper(), match.group("value").strip()
|
||||
return None, text or None
|
||||
|
||||
|
||||
def looks_like_action(key: str | None) -> bool:
|
||||
"""Is a group key an ACTION rather than a sub-heading (t16)?
|
||||
|
||||
``android.intent.action.MAIN`` and ``com.netflix.action.DIAL_START`` are
|
||||
action-shaped; ``Schemes``, ``Non-Data Actions`` and the ``cast:``/``http:``
|
||||
scheme keys are not. Only used when a filter printed no ``Action:`` detail.
|
||||
"""
|
||||
text = (key or "").strip().rstrip(":")
|
||||
return bool(text) and "." in text and " " not in text
|
||||
|
||||
|
||||
def is_likely_internal(action: str | None, component: str | None = None) -> bool:
|
||||
"""Does this candidate LOOK like the app's own internal plumbing (t16)?
|
||||
|
||||
A heuristic, and labelled as one: the four families the plan names are
|
||||
matched anywhere in the action or the component, and the flag only moves a
|
||||
candidate to the END of the ordering (``rank_candidates``) — it never removes
|
||||
it, because the user is the one who decides what is worth registering.
|
||||
"""
|
||||
action_text = action or ""
|
||||
component_text = component or ""
|
||||
return any(
|
||||
marker in action_text or marker in component_text
|
||||
for marker in INTERNAL_MARKERS
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentCandidate:
|
||||
"""One intent an app advertises, as its resolver table describes it (t16).
|
||||
|
||||
``component`` is the ``package/Activity`` the filter belongs to; ``action``
|
||||
is the intent action (never invented: see ``looks_like_action``); the data
|
||||
fields are one concrete match, so ``scheme``/``authority``/``path`` together
|
||||
describe a URI the app accepts. ``path_kind`` is Android's matcher
|
||||
(``GLOB``/``LITERAL``/``PREFIX``) and is kept because ``/title.*`` is a glob,
|
||||
not a path: a caller that treats it as literal builds the wrong URI.
|
||||
"""
|
||||
|
||||
component: str
|
||||
action: str | None = None
|
||||
categories: tuple[str, ...] = ()
|
||||
scheme: str | None = None
|
||||
authority: str | None = None
|
||||
path: str | None = None
|
||||
path_kind: str | None = None
|
||||
likely_internal: bool = False
|
||||
|
||||
@property
|
||||
def key(self) -> tuple:
|
||||
"""The dedup key: everything the candidate means, nothing about its row."""
|
||||
return (
|
||||
self.component,
|
||||
self.action,
|
||||
self.categories,
|
||||
self.scheme,
|
||||
self.authority,
|
||||
self.path,
|
||||
self.path_kind,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""A plain dict, for the options page, diagnostics and JSON."""
|
||||
return {
|
||||
"component": self.component,
|
||||
"action": self.action,
|
||||
"categories": list(self.categories),
|
||||
"scheme": self.scheme,
|
||||
"authority": self.authority,
|
||||
"path": self.path,
|
||||
"path_kind": self.path_kind,
|
||||
"likely_internal": self.likely_internal,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentParseResult:
|
||||
"""What one capture yielded: the candidates, and whether the cap applied."""
|
||||
|
||||
candidates: tuple[IntentCandidate, ...] = ()
|
||||
found: int = 0
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
def has_activity_resolver_table(output: str | None) -> bool:
|
||||
"""Does this capture carry the resolver section at all (t16)?
|
||||
|
||||
THE distinction this module exists to keep: a capture WITH the heading that
|
||||
holds no entries is an ANSWER ("this app advertises nothing we can read"),
|
||||
while a capture WITHOUT it is not an answer at all — the transport handed us
|
||||
something else (an error line, a truncated answer, nothing). Only the former
|
||||
may be cached (``IntentScanCache``); the latter must be retried.
|
||||
"""
|
||||
return bool(output) and ACTIVITY_TABLE_HEADER in output
|
||||
|
||||
|
||||
def activity_resolver_section(output: str | None) -> str | None:
|
||||
"""The ``Activity Resolver Table:`` section of a capture, or None.
|
||||
|
||||
Bounded by the FIRST of the receiver/service/provider headings, so a capture
|
||||
that carries the whole dumpsys cannot leak another table's filters (a
|
||||
receiver's actions are not an activity's intents). None when the section is
|
||||
absent — see ``has_activity_resolver_table`` for why that is not "no intents".
|
||||
"""
|
||||
if not output:
|
||||
return None
|
||||
start = output.find(ACTIVITY_TABLE_HEADER)
|
||||
if start < 0:
|
||||
return None
|
||||
body = output[start + len(ACTIVITY_TABLE_HEADER):]
|
||||
end = len(body)
|
||||
for heading in _SECTION_ENDS:
|
||||
found = body.find(heading)
|
||||
if 0 <= found < end:
|
||||
end = found
|
||||
return body[:end]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry:
|
||||
"""A filter line being read: its component, its key, its own filter id."""
|
||||
|
||||
component: str
|
||||
key: str | None = None
|
||||
filter_id: str | None = None
|
||||
|
||||
@property
|
||||
def group_action(self) -> str | None:
|
||||
"""The group key as an action, when it is action-shaped (else None)."""
|
||||
key = self.key
|
||||
return key.rstrip(":") if looks_like_action(key) else None
|
||||
|
||||
|
||||
def _iter_filters(section: str) -> Iterator[tuple[_Entry, list[tuple[str, str]]]]:
|
||||
"""Yield ``(entry, details)`` for every filter line in the section.
|
||||
|
||||
Details belong to the filter line they follow, so the walk is line-order and
|
||||
resets on each new entry. A group key on its own line is remembered for the
|
||||
action fallback (``_Entry.group_action``).
|
||||
"""
|
||||
entry: _Entry | None = None
|
||||
details: list[tuple[str, str]] = []
|
||||
group_key: str | None = None
|
||||
for line in section.splitlines():
|
||||
match = _ENTRY_RE.match(line)
|
||||
if match:
|
||||
if entry is not None:
|
||||
yield entry, details
|
||||
key = (match.group("key") or "").strip() or group_key
|
||||
entry = _Entry(
|
||||
component=match.group("component"),
|
||||
key=key,
|
||||
filter_id=match.group("filter_id"),
|
||||
)
|
||||
details = []
|
||||
continue
|
||||
detail = _DETAIL_RE.match(line)
|
||||
if detail is not None:
|
||||
if entry is not None:
|
||||
details.append((detail.group("field"), detail.group("value")))
|
||||
continue
|
||||
heading = _GROUP_KEY_RE.match(line)
|
||||
if heading is not None:
|
||||
group_key = heading.group("key")
|
||||
if entry is not None:
|
||||
yield entry, details
|
||||
|
||||
|
||||
def _data_specs(details: list[tuple[str, str]]) -> list[tuple]:
|
||||
"""The ``(scheme, authority, path, path_kind)`` combinations of one filter.
|
||||
|
||||
``Scheme:`` opens a group; the ``Authority:``/``Path:`` lines that follow it
|
||||
belong to it (that is the nesting the capture prints). A filter with none of
|
||||
the three yields a single all-None spec: "this filter matches on its action
|
||||
alone", which is exactly what a Non-Data Action is.
|
||||
"""
|
||||
groups: list[list] = []
|
||||
for field, raw in details:
|
||||
if field == "Scheme":
|
||||
groups.append([_unquote(raw) or None, [], []])
|
||||
continue
|
||||
if field not in ("Authority", "Path"):
|
||||
continue
|
||||
if not groups:
|
||||
groups.append([None, [], []])
|
||||
target = groups[-1]
|
||||
if field == "Authority":
|
||||
value = _authority_value(raw)
|
||||
if value:
|
||||
target[1].append(value)
|
||||
else:
|
||||
target[2].append(_path_value(raw))
|
||||
|
||||
specs: list[tuple] = []
|
||||
for scheme, authorities, paths in groups:
|
||||
for authority in authorities or [None]:
|
||||
for path_kind, path in paths or [(None, None)]:
|
||||
specs.append((scheme, authority, path, path_kind))
|
||||
return specs
|
||||
|
||||
|
||||
def _candidates_for(entry: _Entry, details: list[tuple[str, str]]) -> list[IntentCandidate]:
|
||||
"""Every candidate one filter line yields (action x data combination)."""
|
||||
actions: list[str | None] = []
|
||||
categories: list[str] = []
|
||||
for field, raw in details:
|
||||
if field == "Action":
|
||||
value = _unquote(raw)
|
||||
if value and value not in actions:
|
||||
actions.append(value)
|
||||
elif field == "Category":
|
||||
value = _unquote(raw)
|
||||
if value and value not in categories:
|
||||
categories.append(value)
|
||||
if not actions:
|
||||
# No Action: detail. The group key is the action ONLY when it is
|
||||
# action-shaped; otherwise the candidate keeps action None rather than
|
||||
# inventing one from a sub-heading.
|
||||
actions = [entry.group_action]
|
||||
|
||||
specs = _data_specs(details) or [(None, None, None, None)]
|
||||
candidates: list[IntentCandidate] = []
|
||||
for action in actions:
|
||||
for scheme, authority, path, path_kind in specs:
|
||||
candidates.append(
|
||||
IntentCandidate(
|
||||
component=entry.component,
|
||||
action=action,
|
||||
categories=tuple(categories),
|
||||
scheme=scheme,
|
||||
authority=authority,
|
||||
path=path,
|
||||
path_kind=path_kind,
|
||||
likely_internal=is_likely_internal(action, entry.component),
|
||||
)
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def rank_candidates(
|
||||
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> list[IntentCandidate]:
|
||||
"""Deduplicate, order deterministically, and cap (t16).
|
||||
|
||||
Order: candidates a user is likely to want first (``likely_internal`` last),
|
||||
then the dedup key itself — action, scheme, authority, path, component,
|
||||
categories — so the order is total and identical on a re-scan, which is what
|
||||
makes it safe for a UI to key rows by position.
|
||||
|
||||
The cap (``limit``, default ``INTENT_CANDIDATE_LIMIT``) keeps a pathological
|
||||
table from becoming a wall of rows; it is applied AFTER dedup, drops the
|
||||
least interesting rows first (internal-looking ones) and is DISCLOSED by the
|
||||
caller (``IntentParseResult.truncated``), never silent.
|
||||
"""
|
||||
unique: dict[tuple, IntentCandidate] = {}
|
||||
for candidate in candidates:
|
||||
unique.setdefault(candidate.key, candidate)
|
||||
ordered = sorted(
|
||||
unique.values(),
|
||||
key=lambda c: (
|
||||
c.likely_internal,
|
||||
c.action or "",
|
||||
c.scheme or "",
|
||||
c.authority or "",
|
||||
c.path or "",
|
||||
c.component,
|
||||
c.categories,
|
||||
),
|
||||
)
|
||||
return ordered[:limit]
|
||||
|
||||
|
||||
def parse_intent_report(
|
||||
output: str | None,
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> IntentParseResult:
|
||||
"""Parse a resolver capture into a report: candidates, count, capped or not.
|
||||
|
||||
An absent section yields an EMPTY report, not an answer — callers must ask
|
||||
``has_activity_resolver_table`` (the manager does) before treating "no
|
||||
candidates" as "this app advertises nothing".
|
||||
"""
|
||||
if not output:
|
||||
return IntentParseResult()
|
||||
section = activity_resolver_section(output)
|
||||
if section is None:
|
||||
return IntentParseResult()
|
||||
|
||||
raw: list[IntentCandidate] = []
|
||||
for entry, details in _iter_filters(section):
|
||||
raw.extend(_candidates_for(entry, details))
|
||||
ranked = rank_candidates(raw, limit=limit)
|
||||
deduped = len({candidate.key for candidate in raw})
|
||||
return IntentParseResult(
|
||||
candidates=tuple(ranked),
|
||||
found=deduped,
|
||||
truncated=deduped > len(ranked),
|
||||
)
|
||||
|
||||
|
||||
def parse_activity_resolver_table(
|
||||
output: str | None,
|
||||
*,
|
||||
limit: int = INTENT_CANDIDATE_LIMIT,
|
||||
) -> list[IntentCandidate]:
|
||||
"""The candidates in a ``pm dump`` resolver capture (t16).
|
||||
|
||||
Deduplicated on (component, action, categories, scheme, authority, path),
|
||||
deterministically ordered and capped — see ``rank_candidates``. An absent
|
||||
section returns an empty list: this function reports WHAT IS THERE, and the
|
||||
caller decides whether the capture was an answer at all (the manager checks
|
||||
``has_activity_resolver_table`` first — a failed scan is not an answer and is
|
||||
never cached as one).
|
||||
"""
|
||||
return list(parse_intent_report(output, limit=limit).candidates)
|
||||
|
||||
|
||||
def intent_scan_command(package: str, *, line_limit: int = INTENT_SCAN_LINE_LIMIT) -> str:
|
||||
"""The ON-DEVICE filter: only the resolver section crosses the wire (t16).
|
||||
|
||||
``pm dump <pkg>`` is a whole dumpsys (~2241 lines for one app on the user's
|
||||
Shield), so the range is applied by the device's own ``sed`` and the answer is
|
||||
additionally bounded, which is what makes the transfer proportional to the
|
||||
resolver table rather than to the dump.
|
||||
|
||||
SAFETY: the package id is the only caller-supplied text in the command and it
|
||||
is always ``shlex.quote``d; the sed script is built from the two constants
|
||||
above (no interpolation of anything untrusted), so a package id containing
|
||||
shell metacharacters cannot add a command — the fallback builder is quoted the
|
||||
same way. A caller must still refuse an id that is not package-shaped: the
|
||||
manager does (``is_launchable``).
|
||||
"""
|
||||
sed_range = f"/{SED_RANGE_START}/,/{SED_RANGE_END}/p"
|
||||
return (
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | "
|
||||
f'sed -n "{sed_range}" | '
|
||||
f"head -n {int(line_limit)}"
|
||||
)
|
||||
|
||||
|
||||
def intent_scan_fallback_command(
|
||||
package: str,
|
||||
*,
|
||||
line_limit: int = INTENT_SCAN_LINE_LIMIT,
|
||||
) -> str:
|
||||
"""The bounded ``grep -A`` fallback for a device without sed ranges (t16).
|
||||
|
||||
``pm dump <pkg> 2>/dev/null | grep -A <cap> -m 1 -F 'Activity Resolver Table:'``
|
||||
— the same transfer bound as the primary command, applied with the one tool
|
||||
every Android toolbox has. It is only ever a SECOND attempt: the manager runs
|
||||
it when the primary answer did not contain the section at all, so a device
|
||||
whose sed works never pays for it.
|
||||
"""
|
||||
return (
|
||||
f"pm dump {shlex.quote(package)} 2>/dev/null | "
|
||||
f"grep -A {int(line_limit)} -m 1 -F {shlex.quote(ACTIVITY_TABLE_HEADER)}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentCacheEntry:
|
||||
"""One cached ANSWER: its state, its candidates and when it was written."""
|
||||
|
||||
state: str
|
||||
candidates: tuple[IntentCandidate, ...]
|
||||
stamp: float
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class IntentScanCache:
|
||||
"""A per-package TTL cache of scans that RAN (t16).
|
||||
|
||||
The rule the discovery-cache defect taught us is structural here: ``put`` is
|
||||
the ONLY writer, it is called only by a scan that ran, and it records which
|
||||
of the two answers it holds — ``INTENT_ENTRY_CANDIDATES`` or
|
||||
``INTENT_ENTRY_EMPTY``. A scan whose transport failed never reaches ``put``,
|
||||
so nothing is cached and the next request retries instead of serving a
|
||||
fabricated "this app advertises nothing" for the whole TTL.
|
||||
|
||||
A stale entry is DROPPED when it is read (never served), so callers see
|
||||
either an answer inside its TTL or nothing at all.
|
||||
"""
|
||||
|
||||
def __init__(self, *, ttl: float = INTENT_SCAN_TTL, clock=time.monotonic):
|
||||
self._ttl = ttl
|
||||
self._clock = clock
|
||||
self._entries: dict[str, IntentCacheEntry] = {}
|
||||
|
||||
@property
|
||||
def ttl(self) -> float:
|
||||
return self._ttl
|
||||
|
||||
def get(self, package: str) -> IntentCacheEntry | None:
|
||||
"""The fresh entry for a package, or None (dropping it when stale)."""
|
||||
entry = self._entries.get(package)
|
||||
if entry is None:
|
||||
return None
|
||||
if self._clock() - entry.stamp > self._ttl:
|
||||
self._entries.pop(package, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def put(
|
||||
self,
|
||||
package: str,
|
||||
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
|
||||
*,
|
||||
truncated: bool = False,
|
||||
) -> IntentCacheEntry:
|
||||
"""Record a scan that RAN: its candidates, or the empty ANSWER.
|
||||
|
||||
The state is derived, not passed: an empty candidate list means the
|
||||
device answered with none (``INTENT_ENTRY_EMPTY``) — an answer worth
|
||||
caching — while a non-empty list is ``INTENT_ENTRY_CANDIDATES``.
|
||||
"""
|
||||
saved = tuple(candidates)
|
||||
entry = IntentCacheEntry(
|
||||
state=INTENT_ENTRY_CANDIDATES if saved else INTENT_ENTRY_EMPTY,
|
||||
candidates=saved,
|
||||
stamp=self._clock(),
|
||||
truncated=truncated,
|
||||
)
|
||||
self._entries[package] = entry
|
||||
return entry
|
||||
|
||||
def state(self, package: str | None) -> str | None:
|
||||
"""The cached entry's state, or None when no fresh answer is cached."""
|
||||
if not package:
|
||||
return None
|
||||
entry = self.get(package)
|
||||
return entry.state if entry is not None else None
|
||||
|
||||
def age(self, package: str) -> float | None:
|
||||
"""How long ago the fresh entry was written, or None."""
|
||||
entry = self.get(package)
|
||||
return None if entry is None else self._clock() - entry.stamp
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Forget every entry (tests, and a device switch)."""
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentScanResult:
|
||||
"""The answer to one scan request (t16), status included.
|
||||
|
||||
``candidates`` is only ever an ANSWER when ``answered`` is True. Every other
|
||||
status means we do not know, which is why they all carry no candidates: an
|
||||
ADB-down scan must not look like an app with no intents.
|
||||
"""
|
||||
|
||||
package: str
|
||||
status: str
|
||||
candidates: tuple[IntentCandidate, ...] = ()
|
||||
entry_state: str | None = None
|
||||
age_seconds: float | None = None
|
||||
truncated: bool = False
|
||||
|
||||
@property
|
||||
def answered(self) -> bool:
|
||||
"""Did the DEVICE answer? False means "unknown", never "no intents"."""
|
||||
return self.status in _ANSWERED_STATUSES
|
||||
|
||||
@property
|
||||
def has_candidates(self) -> bool:
|
||||
"""Did the device answer WITH intents?"""
|
||||
return bool(self.candidates)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""A plain dict, for the options page, diagnostics and JSON."""
|
||||
return {
|
||||
"package": self.package,
|
||||
"status": self.status,
|
||||
"answered": self.answered,
|
||||
"entry_state": self.entry_state,
|
||||
"age_seconds": self.age_seconds,
|
||||
"truncated": self.truncated,
|
||||
"candidates": [candidate.as_dict() for candidate in self.candidates],
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,280 @@
|
||||
"""Screen-capture image entity — an OPT-IN diagnostic surface (t66).
|
||||
|
||||
The user asked for "an image entity of the device screen capture? Can that be
|
||||
added". This is that entity, and it is the only thing in the integration that
|
||||
grabs the screen. Three boundaries are deliberate and load-bearing:
|
||||
|
||||
* **It never drives state.** An image entity's state IS its
|
||||
``image_last_updated`` timestamp, and nothing here writes to the manager's
|
||||
store, to the power ladder or to any other entity. A capture cannot make the
|
||||
media player, the sensors or the power switch claim something the device did
|
||||
not report. (The user's original constraint: screen capture must never drive
|
||||
state.)
|
||||
* **It is opt-in, default OFF** (``CONF_SCREEN_CAPTURE_ENABLED``). With the
|
||||
option absent ``async_setup_entry`` adds no entity at all.
|
||||
* **It never polls.** ``ImageEntity`` ships ``_attr_should_poll = False`` ("no
|
||||
need to poll image entities") and this module keeps it. Nothing runs on a
|
||||
timer; a capture happens only when something asks for the bytes (the image
|
||||
proxy serving ``entity_picture``) or when the user calls
|
||||
``homeassistant.update_entity``.
|
||||
|
||||
BINARY-SAFE CAPTURE — base64 over the shell, and why not a device file
|
||||
=====================================================================
|
||||
``AdbDevice.shell()`` returns ``str``: it decodes the shell channel as text, so
|
||||
raw PNG bytes cannot travel through it — ``\\x89PNG`` is not UTF-8 and the
|
||||
undecodable bytes would be mangled on the way out. There are two binary-safe
|
||||
routes and this module takes the SECOND:
|
||||
|
||||
1. ``screencap -p /sdcard/<name>.png`` then ``AdbDevice.pull()`` — the library's
|
||||
real binary path (the ADB sync protocol). It needs the raw ``AdbDevice``
|
||||
object, which the ADB client owns as a private attribute, and it writes to
|
||||
the device's shared storage: something must delete that file afterwards, and
|
||||
a crash between write and delete leaves a full-resolution screenshot behind
|
||||
on the user's device.
|
||||
2. ``screencap -p | base64`` then ``base64.b64decode`` locally. Base64 output is
|
||||
pure ASCII, so the shell's text decoding cannot corrupt it, and there is no
|
||||
device-side file at all — nothing to clean up, nothing to leak.
|
||||
|
||||
Route 2 costs ~33% more bytes on the socket than route 1, and one claim is
|
||||
INFERRED rather than measured: no Shield was available here, so how the
|
||||
persistent ADB socket handles a few hundred kilobytes of base64 in one
|
||||
``shell()`` call is untested on hardware (the README says so). It is still the
|
||||
right trade: it uses the PUBLIC seam the watchdog's socket already offers
|
||||
(``manager.adb_poller.shell``), leaves no artifact on the device, and its
|
||||
correctness on the wire is provable here with a real PNG fixture.
|
||||
|
||||
One capture costs exactly ONE shell, whatever the caller does: concurrent
|
||||
requests share a lock, and a successful capture is reused for
|
||||
``CAPTURE_REUSE_SECONDS`` so a dashboard repainting cannot hammer the device.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import time
|
||||
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_SCREEN_CAPTURE_ENABLED, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
#: ``screencap -p`` writes PNG to stdout; ``base64`` makes it ASCII, which is the
|
||||
#: whole trick — see the module docstring.
|
||||
SCREEN_CAPTURE_COMMAND = "screencap -p | base64"
|
||||
|
||||
#: The PNG file signature. Nothing but real PNG bytes ever leaves this module.
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
#: A successful capture is reused for this long. Several image fetches in a row
|
||||
#: (a dashboard reload, two clients, a stream preview) must not become one screen
|
||||
#: grab each — "cannot hammer the device" is a property of the code, not a hope.
|
||||
CAPTURE_REUSE_SECONDS = 5.0
|
||||
|
||||
#: The reason the entity is (un)available, published as ``capture_status``.
|
||||
STATUS_OK = "ok"
|
||||
STATUS_ADB_DISABLED = "adb_disabled"
|
||||
STATUS_ADB_DISCONNECTED = "adb_disconnected"
|
||||
STATUS_CAPTURE_FAILED = "capture_failed"
|
||||
|
||||
#: The attribute HA reads beside the state — declared unrecorded (G12).
|
||||
ATTR_CAPTURE_STATUS = "capture_status"
|
||||
|
||||
|
||||
def decode_screen_capture(output: str | None) -> bytes | None:
|
||||
"""Decode one ``screencap -p | base64`` answer into PNG bytes, or None.
|
||||
|
||||
Pure on purpose, so the one part that can corrupt an image is testable
|
||||
without a device: the answer is base64 with ``base64``'s line wrapping (the
|
||||
whitespace is not part of the payload), the decoded bytes MUST carry the PNG
|
||||
signature, and every other outcome — empty output, a shell that never
|
||||
answered, a device that printed an error line instead — is a failure that
|
||||
returns None rather than a corrupt or zero-byte image.
|
||||
"""
|
||||
if not output:
|
||||
return None
|
||||
try:
|
||||
data = base64.b64decode("".join(output.split()), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
return None
|
||||
# The signature alone is not a PNG: the spec puts IHDR as the FIRST chunk,
|
||||
# immediately after it (length 13, type "IHDR"). A shell that returned the
|
||||
# signature plus junk — or a device that answered with a header in another
|
||||
# format — fails here. What CANNOT be detected locally is a truncation AFTER
|
||||
# the header: those bytes are still a well-formed PNG prefix, and the
|
||||
# consumer (HA's image proxy, then the browser) is what rejects them.
|
||||
if data[12:16] != b"IHDR":
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the screen-capture entity — only when the user opted in (t66)."""
|
||||
if not entry.options.get(CONF_SCREEN_CAPTURE_ENABLED, False):
|
||||
return # default OFF: no entity, no capture, no cost
|
||||
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
if manager is None:
|
||||
_LOGGER.debug("Screen capture skipped: no runtime manager on the entry")
|
||||
return
|
||||
|
||||
async_add_entities([NvidiaShieldScreenImage(hass, entry)])
|
||||
|
||||
|
||||
class NvidiaShieldScreenImage(ImageEntity):
|
||||
"""The Shield's current screen as a PNG — manual, diagnostic, never a source.
|
||||
|
||||
A capture is fetched on demand and nothing else: the entity publishes no
|
||||
command, writes no state source and is not polled. Its state is the
|
||||
timestamp of the last successful capture, and its ``capture_status``
|
||||
attribute names why it cannot serve an image when it cannot.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "screen_capture"
|
||||
_attr_content_type = "image/png"
|
||||
# HA's own default for image entities, kept explicitly: this entity must
|
||||
# never turn into a timer that grabs the screen every scan interval.
|
||||
_attr_should_poll = False
|
||||
# G12: a poll-adjacent value must not bloat the recorder database, and the
|
||||
# image bytes are never part of the state at all.
|
||||
_unrecorded_attributes = frozenset({ATTR_CAPTURE_STATUS})
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialise the entity over the entry's runtime manager.
|
||||
|
||||
The two-argument shape every platform in this integration uses: the
|
||||
manager travels on ``entry.runtime_data`` (t40's wiring), so the real-HA
|
||||
entity matrix can build this class exactly like the others.
|
||||
"""
|
||||
super().__init__(hass)
|
||||
# ImageEntity.__init__ builds HA's shared httpx client but does NOT set
|
||||
# ``hass`` (the platform does that in production). The rest of this
|
||||
# integration's entities assign it themselves, and without it HA's own
|
||||
# state write raises "Attribute hass is None" — measured in the real-HA
|
||||
# matrix, which builds entities the same way the tests do.
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._png: bytes | None = None
|
||||
self._captured_at: float = 0.0
|
||||
# One shell at a time, however many callers ask for the image.
|
||||
self._lock = asyncio.Lock()
|
||||
poller = self._adb_poller()
|
||||
self._status = (
|
||||
STATUS_ADB_DISABLED
|
||||
if poller is None
|
||||
else STATUS_OK
|
||||
if poller.connected
|
||||
else STATUS_ADB_DISCONNECTED
|
||||
)
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_screen_capture"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
manufacturer="NVIDIA",
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
# -- availability ------------------------------------------------------
|
||||
|
||||
def _adb_poller(self):
|
||||
"""The ADB client this entry owns, or None when ADB is off/not built."""
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
if manager is None:
|
||||
return None
|
||||
return getattr(manager, "adb_poller", None)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""True only while a capture can actually be served.
|
||||
|
||||
Unavailable is the honest answer for "ADB is off", "ADB is down" and "the
|
||||
last capture failed"; the alternative — being available and handing HA an
|
||||
empty image — is exactly the blank screenshot this must never produce.
|
||||
"""
|
||||
return self._status == STATUS_OK
|
||||
@property
|
||||
def capture_status(self) -> str:
|
||||
"""Why the entity is (un)available — the same value as the attribute."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The reason behind the availability, as a PROPERTY (HA reads attrs)."""
|
||||
return {ATTR_CAPTURE_STATUS: self._status}
|
||||
|
||||
# -- the capture path --------------------------------------------------
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return the PNG bytes, capturing on demand (the image proxy seam)."""
|
||||
return await self._async_capture()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Explicit refresh (``homeassistant.update_entity``) — skip the reuse.
|
||||
|
||||
This is also how a failed capture is retried: a failure makes the entity
|
||||
unavailable on purpose, and an explicit refresh is the user's way back
|
||||
without waiting for the next dashboard fetch.
|
||||
"""
|
||||
await self._async_capture(force=True)
|
||||
|
||||
async def _async_capture(self, *, force: bool = False) -> bytes | None:
|
||||
"""One screen grab, shared by everyone who asks within the reuse window."""
|
||||
async with self._lock:
|
||||
if (
|
||||
not force
|
||||
and self._png is not None
|
||||
and time.monotonic() - self._captured_at < CAPTURE_REUSE_SECONDS
|
||||
):
|
||||
return self._png
|
||||
|
||||
poller = self._adb_poller()
|
||||
if poller is None:
|
||||
return self._fail(STATUS_ADB_DISABLED)
|
||||
if not poller.connected:
|
||||
return self._fail(STATUS_ADB_DISCONNECTED)
|
||||
try:
|
||||
output = await poller.shell(SCREEN_CAPTURE_COMMAND)
|
||||
except Exception as err: # the transport is remote; it may raise anything
|
||||
# ``AdbClientBase.shell`` already turns its own failures into
|
||||
# None, so this is defence in depth — a raised error must still
|
||||
# end as "unavailable", never as a blank image in HA.
|
||||
_LOGGER.warning("Screen capture failed on %s: %s", self._entry.title, err)
|
||||
return self._fail(STATUS_CAPTURE_FAILED)
|
||||
data = decode_screen_capture(output)
|
||||
if data is None:
|
||||
# Never a blank or zero-byte image: no answer, a truncated one,
|
||||
# an error line or a non-PNG all land here.
|
||||
_LOGGER.warning(
|
||||
"Screen capture from %s did not return a PNG (%d bytes of "
|
||||
"shell output)",
|
||||
self._entry.title,
|
||||
len(output or ""),
|
||||
)
|
||||
return self._fail(STATUS_CAPTURE_FAILED)
|
||||
|
||||
self._png = data
|
||||
self._captured_at = time.monotonic()
|
||||
self._status = STATUS_OK
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
return data
|
||||
|
||||
def _fail(self, status: str) -> None:
|
||||
"""Record why no image can be served and stay unavailable."""
|
||||
self._status = status
|
||||
return None
|
||||
@@ -1,16 +1,25 @@
|
||||
{
|
||||
"domain": "nvidia_shield",
|
||||
"name": "NVIDIA Shield",
|
||||
"codeowners": [
|
||||
"@ltomes"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
|
||||
"requirements": [
|
||||
"adb-shell>=0.4.0"
|
||||
],
|
||||
"version": "0.1.1"
|
||||
"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.2",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
"integration_type": "device",
|
||||
"loggers": [
|
||||
"adb_shell",
|
||||
"androidtvremote2"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,530 @@
|
||||
"""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;
|
||||
- ``RemoteEntityFeature.ACTIVITY`` with an ``activity_list`` built from OUR app
|
||||
directory — the library exposes NO app-list API (measured), which is exactly why
|
||||
core's list is user-maintained too — so the app/activity picker matches core;
|
||||
- ``current_activity`` resolves the foreground push (protocol first, ADB parse as
|
||||
the fallback) through the one ``resolve_app_name`` chain, and stays None while
|
||||
nothing is known: no fabricated activity, ever;
|
||||
- ``turn_on(activity=...)`` wakes when the device says it is off and then launches
|
||||
through the same path ``select_source`` uses; an activity name the directory does
|
||||
not carry is a ``ServiceValidationError``, never a silent no-op;
|
||||
- the transport's REAL state is published in ``extra_state_attributes`` so a
|
||||
never-connected transport cannot look like a paired one (no ``is_paired`` exists:
|
||||
a session or a push is the only proof);
|
||||
- a configured command script still owns turn_on/turn_off and never touches the
|
||||
protocol, so a press that worked through it says NOTHING about pairing — the
|
||||
attributes name which route each power command takes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from homeassistant.components.remote import (
|
||||
ATTR_ACTIVITY,
|
||||
RemoteEntity,
|
||||
RemoteEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_APP_NAME_MAP,
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
command_scripts,
|
||||
remote_command_script,
|
||||
)
|
||||
from .helpers import protocol_cert_paths
|
||||
from .media_player import (
|
||||
activity_names,
|
||||
app_directory,
|
||||
async_call_script,
|
||||
async_run_command_script,
|
||||
entity_available,
|
||||
resolve_app_name,
|
||||
)
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def _looks_launchable(value: str) -> bool:
|
||||
"""True for a package id or a detail link — forms the launch path accepts (t15).
|
||||
|
||||
The rule is the repo's own definition of a package (``_valid_app_row``: dotted,
|
||||
no spaces) plus URL-shaped deep links, which is exactly what
|
||||
``send_launch_app_command`` handles. It is deliberately NOT a guess that an id
|
||||
exists: the device answers that. It exists so "Netflx" (a typo of a listed name)
|
||||
is refused while an app the directory does not carry can still be launched.
|
||||
"""
|
||||
if not value or " " in value:
|
||||
return False
|
||||
return "." in value or "://" in value
|
||||
|
||||
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.
|
||||
|
||||
t15: this is the entity the app/activity picker hangs off. HA renders the picker
|
||||
only when ``supported_features`` carries ``RemoteEntityFeature.ACTIVITY`` — the
|
||||
user's "the UI doesn't actually show any buttons like the android tv remote
|
||||
does" — and the list itself has to come from OUR directory, because
|
||||
``AndroidTVRemote`` exposes no app-list API at all (measured on the installed
|
||||
library; core androidtv_remote keeps a user-maintained map for the same reason).
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
#: The feature flag that makes HA offer the activity/app picker at all.
|
||||
_attr_supported_features = RemoteEntityFeature.ACTIVITY
|
||||
|
||||
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"
|
||||
# t15: the activity list is available from the FIRST render (a real-HA state
|
||||
# write needs no entity platform), and is refreshed with discovered apps once
|
||||
# ADB can answer (``async_added_to_hass``).
|
||||
self._attr_activity_list = activity_names(self._entry.options)
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
#: Which protocol pushes have EVER arrived — the only pairing evidence the
|
||||
#: library can offer (it has no is_paired). Sticky on purpose: the store
|
||||
#: expires every field after STATE_STORE_TTL, so "has it ever arrived?"
|
||||
#: cannot be read from the store.
|
||||
self._protocol_pushes_seen: set[str] = set()
|
||||
self._note_protocol_pushes()
|
||||
#: "present"/"missing"/"unknown" — the shared setup certificate, which is
|
||||
#: why enabling the transport on a paired entry asks for no new PIN.
|
||||
self._protocol_certificate = "unknown"
|
||||
self._activity_refresh_task = None
|
||||
|
||||
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||
return getattr(self._entry, "runtime_data", None)
|
||||
|
||||
def _resolve_activity_name(self) -> str | None:
|
||||
"""The foreground app as a friendly name (t15), or None when nothing is known.
|
||||
|
||||
The same chain as the media player's ``app_name`` and the Current App sensor
|
||||
(``resolve_app_name``): the user's map, then the cached device label, then the
|
||||
curated table, then the raw id — so an activity and a source can never
|
||||
disagree about what is on screen. The foreground identity prefers the protocol
|
||||
push and falls back to the ADB parse, which keeps this entity useful on an
|
||||
ADB-only setup (its protocol surface alone degrades).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return None
|
||||
return resolve_app_name(
|
||||
manager.foreground_app,
|
||||
self._entry.options.get(CONF_APP_NAME_MAP) or {},
|
||||
manager.app_label,
|
||||
)
|
||||
|
||||
def _note_protocol_pushes(self) -> None:
|
||||
"""Remember which device pushes have arrived (t15) — sticky, see __init__."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if manager.current_app is not None:
|
||||
self._protocol_pushes_seen.add("current_app")
|
||||
if manager.is_on is not None:
|
||||
self._protocol_pushes_seen.add("is_on")
|
||||
if manager.is_available is not None:
|
||||
self._protocol_pushes_seen.add("is_available")
|
||||
if manager.volume_info is not None:
|
||||
self._protocol_pushes_seen.add("volume")
|
||||
|
||||
@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:
|
||||
"""The raw foreground package (protocol push; ADB parse as the fallback).
|
||||
|
||||
Kept alongside HA's ``current_activity``: this is the id (what
|
||||
``turn_on(activity=...)`` accepts and what the Current App sensor stamps),
|
||||
while ``current_activity`` is the friendly name HA displays.
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
return manager.foreground_app
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The transport's real state — the pairing answer, readable from HA (t15).
|
||||
|
||||
There is no "is it paired?" API to call (the library exposes none either,
|
||||
measured), so everything here is a fact that can be PROVEN:
|
||||
|
||||
* ``protocol_state`` — ``off`` / ``never_connected`` / ``connected`` /
|
||||
``disconnected`` / ``auth_rejected``. ``never_connected`` is the state
|
||||
that used to be permanent (``keep_reconnecting()`` alone never connects),
|
||||
and it must never look like ``connected``.
|
||||
* ``protocol_connected`` / ``protocol_ever_authenticated`` — a session or a
|
||||
device push is the only proof of an authenticated (paired) client.
|
||||
* ``protocol_current_app_seen`` / ``protocol_is_on_seen`` /
|
||||
``protocol_volume_seen`` — whether each device push has EVER arrived.
|
||||
* ``protocol_last_error`` / ``protocol_failures`` — a send that could not
|
||||
run is recorded here and logged, never swallowed into a debug line. The
|
||||
ERROR is the failure the CURRENT state has not recovered from (t20/O1):
|
||||
it is cleared as soon as a session is proven again — the next connect or
|
||||
the transport's own availability push — so it can never read as a
|
||||
problem beside ``protocol_connected: true``, and a failure that happened
|
||||
AFTER the session came up is kept because that one is still current.
|
||||
``protocol_failures`` keeps counting, so a recovery stays visible as
|
||||
history instead of as a stale message.
|
||||
* ``protocol_certificate`` — the setup pairing's shared ``client.pem`` is
|
||||
still on disk, which is exactly why enabling the transport asks for no
|
||||
new PIN (the other Android TV integration pairs its own client).
|
||||
* ``power_on_route`` / ``power_off_route`` — ``script:<id>`` when a command
|
||||
script owns that command. That path never touches the protocol, so a
|
||||
success through it says NOTHING about pairing.
|
||||
"""
|
||||
return {
|
||||
"activity": self.activity,
|
||||
# t15: which tier produced it — a protocol push must never win SILENTLY
|
||||
# over the ADB parse the user's setup is built on (see the manager's
|
||||
# ``foreground_source``).
|
||||
"activity_source": self._protocol_read("foreground_source"),
|
||||
"power_on_route": self._power_route("turn_on"),
|
||||
"power_off_route": self._power_route("turn_off"),
|
||||
"protocol_active": self._protocol_active(),
|
||||
"protocol_state": self._protocol_state(),
|
||||
"protocol_connected": self._protocol_flag("protocol_connected"),
|
||||
"protocol_ever_authenticated": self._protocol_flag(
|
||||
"protocol_ever_authenticated"
|
||||
),
|
||||
"protocol_last_error": self._protocol_read("protocol_last_error"),
|
||||
"protocol_failures": self._protocol_read("protocol_failures"),
|
||||
"protocol_certificate": self._protocol_certificate,
|
||||
"protocol_current_app_seen": "current_app" in self._protocol_pushes_seen,
|
||||
"protocol_is_on_seen": "is_on" in self._protocol_pushes_seen,
|
||||
"protocol_volume_seen": "volume" in self._protocol_pushes_seen,
|
||||
}
|
||||
|
||||
def _protocol_read(self, name: str, default=None):
|
||||
"""Read one transport fact off the manager, or the default (stub managers)."""
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return default
|
||||
return getattr(manager, name, default)
|
||||
|
||||
def _protocol_active(self) -> bool:
|
||||
return bool(self._protocol_read("protocol_active", False))
|
||||
|
||||
def _protocol_state(self) -> str:
|
||||
"""The transport state, with ``off`` for a manager that carries no client."""
|
||||
if not self._protocol_active():
|
||||
return "off"
|
||||
return self._protocol_read("protocol_state", "never_connected")
|
||||
|
||||
def _protocol_flag(self, name: str) -> bool:
|
||||
return bool(self._protocol_read(name, False))
|
||||
|
||||
def _power_route(self, command: str) -> str:
|
||||
"""Which path ``turn_on``/``turn_off`` takes: a script, or the protocol (t15).
|
||||
|
||||
A configured command script owns the sequence and never touches the
|
||||
protocol — so "the power press worked" through it is NOT evidence that the
|
||||
transport is paired, or even connected. That is the conclusion the user drew
|
||||
and then doubted, made readable instead of inferred.
|
||||
"""
|
||||
script = (command_scripts(self._entry.options) or {}).get(command)
|
||||
return f"script:{script}" if script else "protocol"
|
||||
|
||||
|
||||
@callback
|
||||
def _refresh_state(self) -> None:
|
||||
"""Manager store changed — re-resolve activity, availability, transport."""
|
||||
self._manager = self._manager_of()
|
||||
self._note_protocol_pushes()
|
||||
self._attr_current_activity = self._resolve_activity_name()
|
||||
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)
|
||||
self._note_protocol_pushes()
|
||||
await self._async_note_certificate()
|
||||
self._schedule_activity_refresh()
|
||||
|
||||
async def _async_note_certificate(self) -> None:
|
||||
"""Record whether the shared pairing certificate exists (t15).
|
||||
|
||||
This is the "no new PIN is expected" evidence, and it is a design fact, not a
|
||||
guess: ``protocol_cert_paths`` is shared between the config flow's pairing step
|
||||
and this runtime, so an entry paired at setup reuses that ``client.pem`` when
|
||||
the transport is (re-)enabled. The OTHER Android TV integration pairs its own
|
||||
client and shares nothing with us.
|
||||
"""
|
||||
add_executor_job = getattr(self.hass, "async_add_executor_job", None)
|
||||
if add_executor_job is None:
|
||||
return # a hass stand-in: leave it "unknown" rather than guess
|
||||
try:
|
||||
certfile, keyfile = await add_executor_job(protocol_cert_paths, self.hass)
|
||||
except Exception: # pragma: no cover - a filesystem we cannot read
|
||||
return
|
||||
self._protocol_certificate = (
|
||||
"present" if os.path.exists(certfile) and os.path.exists(keyfile) else "missing"
|
||||
)
|
||||
|
||||
def _schedule_activity_refresh(self) -> None:
|
||||
"""Add the apps ADB discovery actually found to the activity list (t15).
|
||||
|
||||
One bounded sweep, on demand — the t57 path the options editor uses — never a
|
||||
per-state-write shell. The list is already complete for a configured
|
||||
directory, and a sweep that cannot run changes nothing (no fabrication).
|
||||
"""
|
||||
manager = self._manager_of()
|
||||
create_task = getattr(self.hass, "async_create_task", None)
|
||||
if create_task is None or not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
if not callable(getattr(manager, "async_refresh_installed_apps", None)):
|
||||
return # a manager stand-in: the directory alone is the honest list
|
||||
self._activity_refresh_task = create_task(
|
||||
self._async_refresh_activity_list(manager),
|
||||
"nvidia_shield_remote_activities",
|
||||
)
|
||||
|
||||
async def _async_refresh_activity_list(self, manager) -> None:
|
||||
"""Fold discovered apps into the activity list (t15) — one write if it grew."""
|
||||
try:
|
||||
rows = await manager.async_refresh_installed_apps()
|
||||
except Exception: # pragma: no cover - discovery never breaks the entity
|
||||
return
|
||||
names = activity_names(self._entry.options, rows)
|
||||
if names != self._attr_activity_list:
|
||||
self._attr_activity_list = names
|
||||
self.async_write_ha_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, and launch a requested activity (t15).
|
||||
|
||||
With NO activity — or an empty one — this behaves exactly as before: the
|
||||
configured script owns the wake sequence when there is one, otherwise POWER
|
||||
goes out only when the device says it is off (unknown counts as "maybe on",
|
||||
so nothing is toggled blindly).
|
||||
|
||||
With ``activity``, the same wake happens first (a script that owns turn_on
|
||||
still owns it — it never touches the protocol), and then the app is launched
|
||||
over the protocol through the same path ``select_source`` uses. A name the
|
||||
activity list does not carry, and that is not a package/detail link either,
|
||||
raises ``ServiceValidationError``: an activity request that cannot launch
|
||||
must never look like it worked.
|
||||
"""
|
||||
activity = str(kwargs.get(ATTR_ACTIVITY) or "").strip()
|
||||
await self._async_wake()
|
||||
if activity:
|
||||
await self._async_launch_activity(activity)
|
||||
|
||||
async def _async_wake(self) -> None:
|
||||
"""Turn the device on: the configured script first, else the POWER key (t44).
|
||||
|
||||
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_launch_activity(self, activity: str) -> None:
|
||||
"""Launch an activity through the shared launch path (t15).
|
||||
|
||||
The target comes from the same directory the activity list is built from, so
|
||||
anything the picker offers resolves. A package id or a deep link passes
|
||||
through: that is how an app the directory does not carry can still be
|
||||
launched, and what core does with an activity it does not know. Anything else
|
||||
is a request nothing can launch and raises instead of doing nothing.
|
||||
"""
|
||||
target = self._activity_target(activity)
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="protocol_unavailable",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
if not await manager.launch_app(target):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="activity_launch_failed",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
def _activity_target(self, activity: str) -> str:
|
||||
"""Activity name (or id) → what the launch path takes (t15).
|
||||
|
||||
A listed NAME wins, compared case-insensitively — the same rule the
|
||||
``source_list``/``select_source`` pair uses, so the picker can never offer a
|
||||
name this does not resolve. A ``CONF_APP_NAME_MAP``-less entry still resolves
|
||||
the curated names, because both read the one ``app_directory``.
|
||||
"""
|
||||
directory = app_directory(self._entry.options)
|
||||
wanted = activity.casefold()
|
||||
for package, name in directory.items():
|
||||
if name.casefold() == wanted:
|
||||
return package
|
||||
if activity in directory or _looks_launchable(activity):
|
||||
return activity
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_activity",
|
||||
translation_placeholders={"activity": activity},
|
||||
)
|
||||
|
||||
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,194 @@ 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 device label → the built-in app
|
||||
names (t66) → the package text itself. A bare or unknown package shows its
|
||||
package text rather than None, like the media player's app_name, and that
|
||||
text is the id ONCE — never a ``pkg=pkg`` pair.
|
||||
|
||||
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:
|
||||
@@ -144,23 +300,24 @@ class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||
return {"playback_state": "idle"}
|
||||
|
||||
attrs = state.attributes
|
||||
result = {
|
||||
"playback_state": state.state,
|
||||
"title": attrs.get("media_title"),
|
||||
"media_type": attrs.get("media_content_type"),
|
||||
}
|
||||
|
||||
if attrs.get("media_series_title"):
|
||||
result["series"] = attrs["media_series_title"]
|
||||
if attrs.get("media_season") is not None:
|
||||
result["season"] = attrs["media_season"]
|
||||
if attrs.get("media_episode") is not None:
|
||||
result["episode"] = attrs["media_episode"]
|
||||
if attrs.get("media_duration") is not None:
|
||||
result["duration"] = attrs["media_duration"]
|
||||
if attrs.get("media_position") is not None:
|
||||
result["position"] = attrs["media_position"]
|
||||
if attrs.get("entity_picture"):
|
||||
result["thumbnail"] = attrs["entity_picture"]
|
||||
# t65: the same "no None rows" rule the media player's flattened surface
|
||||
# follows — an attribute that reads `title: null` in the template editor
|
||||
# is noise, so a field the Jellyfin entity did not answer is OMITTED
|
||||
# rather than published. Zero stays: 0 s of position is a real reading.
|
||||
result = {"playback_state": state.state}
|
||||
for key, source in (
|
||||
("title", "media_title"),
|
||||
("media_type", "media_content_type"),
|
||||
("series", "media_series_title"),
|
||||
("season", "media_season"),
|
||||
("episode", "media_episode"),
|
||||
("duration", "media_duration"),
|
||||
("position", "media_position"),
|
||||
("thumbnail", "entity_picture"),
|
||||
):
|
||||
value = attrs.get(source)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
@@ -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,547 @@
|
||||
"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": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
|
||||
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
|
||||
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.",
|
||||
"invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.",
|
||||
"unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.",
|
||||
"invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja."
|
||||
},
|
||||
"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",
|
||||
"intents": "Intents (buttons and send_intent)"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"screen_capture_enabled": "Add a screen capture image entity",
|
||||
"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.",
|
||||
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
|
||||
"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": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"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. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"intents": {
|
||||
"title": "Intents",
|
||||
"description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}",
|
||||
"menu_options": {
|
||||
"intents_edit": "Add or edit an intent",
|
||||
"intents_remove": "Remove an intent",
|
||||
"intents_scan": "Scan an app's intents",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"intents_edit": {
|
||||
"title": "Add or edit an intent",
|
||||
"description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.",
|
||||
"data": {
|
||||
"intent_choice": "Intent"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart."
|
||||
}
|
||||
},
|
||||
"intents_form": {
|
||||
"title": "The intent",
|
||||
"description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.",
|
||||
"data": {
|
||||
"intent_name": "Name",
|
||||
"intent_package": "Package id",
|
||||
"intent_activity": "Activity (component)",
|
||||
"intent_action": "Action",
|
||||
"intent_categories": "Categories",
|
||||
"intent_data": "Data (URL template)",
|
||||
"intent_fields": "Variables (declared fields)"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.",
|
||||
"intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.",
|
||||
"intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.",
|
||||
"intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.",
|
||||
"intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.",
|
||||
"intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.",
|
||||
"intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high."
|
||||
}
|
||||
},
|
||||
"intents_remove": {
|
||||
"title": "Remove an intent",
|
||||
"description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.",
|
||||
"data": {
|
||||
"intent_choice": "Intent"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_choice": "The intent to remove from this entry's options."
|
||||
}
|
||||
},
|
||||
"intents_remove_confirm": {
|
||||
"title": "Remove this intent?",
|
||||
"description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.",
|
||||
"menu_options": {
|
||||
"intents_remove_yes": "Yes, remove it",
|
||||
"intents_remove_no": "No, keep it"
|
||||
}
|
||||
},
|
||||
"intents_scan": {
|
||||
"title": "Scan an app's intents",
|
||||
"description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}",
|
||||
"data": {
|
||||
"intent_scan_package": "App to scan"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id."
|
||||
}
|
||||
},
|
||||
"intents_scan_result": {
|
||||
"title": "Discovered intents",
|
||||
"description": "{scan_note}",
|
||||
"data": {
|
||||
"intent_candidate": "Intent to register"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_intent": {
|
||||
"name": "Send intent",
|
||||
"description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.",
|
||||
"fields": {
|
||||
"intent": {
|
||||
"name": "Intent",
|
||||
"description": "Which registered intent to send, by the name the Intents screen shows."
|
||||
},
|
||||
"variables": {
|
||||
"name": "Variables",
|
||||
"description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty — nothing was sent."
|
||||
},
|
||||
"unsupported_target": {
|
||||
"title": "Unsupported entity for this action",
|
||||
"message": "{entity_id} is not a participant of {service} — nothing was 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}"
|
||||
},
|
||||
"no_target": {
|
||||
"title": "No Shield selected",
|
||||
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
|
||||
},
|
||||
"ambiguous_target": {
|
||||
"title": "More than one Shield selected",
|
||||
"message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent."
|
||||
},
|
||||
"unknown_activity": {
|
||||
"title": "Unknown activity",
|
||||
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
|
||||
},
|
||||
"activity_launch_failed": {
|
||||
"title": "Activity could not launch",
|
||||
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
|
||||
},
|
||||
"protocol_unavailable": {
|
||||
"title": "Remote-protocol transport unavailable",
|
||||
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
|
||||
},
|
||||
"unknown_intent": {
|
||||
"title": "Unknown intent",
|
||||
"message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents."
|
||||
},
|
||||
"missing_intent_field": {
|
||||
"title": "Missing variable",
|
||||
"message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent."
|
||||
},
|
||||
"unknown_intent_variable": {
|
||||
"title": "Undeclared variable",
|
||||
"message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables."
|
||||
},
|
||||
"invalid_intent_field": {
|
||||
"title": "Wrong value type",
|
||||
"message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent."
|
||||
},
|
||||
"invalid_intent_variables": {
|
||||
"title": "Variables must be a mapping",
|
||||
"message": "'variables' must map field names to values for '{intent}' — nothing was sent."
|
||||
},
|
||||
"unresolved_intent_placeholder": {
|
||||
"title": "Unfilled placeholder",
|
||||
"message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent."
|
||||
},
|
||||
"intents_need_adb": {
|
||||
"title": "ADB is required for intents",
|
||||
"message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent."
|
||||
},
|
||||
"intent_no_answer": {
|
||||
"title": "The Shield did not answer",
|
||||
"message": "The Shield did not answer for {entity_id} — the intent was not sent."
|
||||
},
|
||||
"intent_failed": {
|
||||
"title": "The Shield refused the intent",
|
||||
"message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_app": {
|
||||
"name": "Current app"
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"screen_capture": {
|
||||
"name": "Screen capture"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,69 +3,547 @@
|
||||
"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": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
|
||||
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
|
||||
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.",
|
||||
"invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.",
|
||||
"unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.",
|
||||
"invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja."
|
||||
},
|
||||
"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",
|
||||
"intents": "Intents (buttons and send_intent)"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"screen_capture_enabled": "Add a screen capture image entity",
|
||||
"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.",
|
||||
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
|
||||
"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": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
|
||||
"menu_options": {
|
||||
"apps_text": "Edit the whole list as text",
|
||||
"apps_add": "Add an app",
|
||||
"apps_edit": "Edit an app",
|
||||
"apps_remove": "Remove an app",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"apps_text": {
|
||||
"title": "Apps as text",
|
||||
"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. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"apps_add": {
|
||||
"title": "Add an app",
|
||||
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
|
||||
"data": {
|
||||
"app_package": "Package id",
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
|
||||
}
|
||||
},
|
||||
"apps_edit": {
|
||||
"title": "Edit an app",
|
||||
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
|
||||
}
|
||||
},
|
||||
"apps_edit_form": {
|
||||
"title": "Edit an app",
|
||||
"description": "Editing {app}. The package id identifies the row and is not changed here.",
|
||||
"data": {
|
||||
"app_name": "Name to show",
|
||||
"app_icon": "Icon (optional)"
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
|
||||
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
|
||||
}
|
||||
},
|
||||
"apps_remove": {
|
||||
"title": "Remove an app",
|
||||
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
|
||||
"data": {
|
||||
"app_package": "App"
|
||||
},
|
||||
"data_description": {
|
||||
"app_package": "The rows saved so far. The confirmation is next."
|
||||
}
|
||||
},
|
||||
"apps_remove_confirm": {
|
||||
"title": "Remove an app",
|
||||
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
|
||||
"menu_options": {
|
||||
"apps_remove_yes": "Remove it",
|
||||
"apps_remove_no": "Keep it"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"intents": {
|
||||
"title": "Intents",
|
||||
"description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}",
|
||||
"menu_options": {
|
||||
"intents_edit": "Add or edit an intent",
|
||||
"intents_remove": "Remove an intent",
|
||||
"intents_scan": "Scan an app's intents",
|
||||
"menu_again": "Back to the main menu"
|
||||
}
|
||||
},
|
||||
"intents_edit": {
|
||||
"title": "Add or edit an intent",
|
||||
"description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.",
|
||||
"data": {
|
||||
"intent_choice": "Intent"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart."
|
||||
}
|
||||
},
|
||||
"intents_form": {
|
||||
"title": "The intent",
|
||||
"description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.",
|
||||
"data": {
|
||||
"intent_name": "Name",
|
||||
"intent_package": "Package id",
|
||||
"intent_activity": "Activity (component)",
|
||||
"intent_action": "Action",
|
||||
"intent_categories": "Categories",
|
||||
"intent_data": "Data (URL template)",
|
||||
"intent_fields": "Variables (declared fields)"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.",
|
||||
"intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.",
|
||||
"intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.",
|
||||
"intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.",
|
||||
"intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.",
|
||||
"intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.",
|
||||
"intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high."
|
||||
}
|
||||
},
|
||||
"intents_remove": {
|
||||
"title": "Remove an intent",
|
||||
"description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.",
|
||||
"data": {
|
||||
"intent_choice": "Intent"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_choice": "The intent to remove from this entry's options."
|
||||
}
|
||||
},
|
||||
"intents_remove_confirm": {
|
||||
"title": "Remove this intent?",
|
||||
"description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.",
|
||||
"menu_options": {
|
||||
"intents_remove_yes": "Yes, remove it",
|
||||
"intents_remove_no": "No, keep it"
|
||||
}
|
||||
},
|
||||
"intents_scan": {
|
||||
"title": "Scan an app's intents",
|
||||
"description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}",
|
||||
"data": {
|
||||
"intent_scan_package": "App to scan"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id."
|
||||
}
|
||||
},
|
||||
"intents_scan_result": {
|
||||
"title": "Discovered intents",
|
||||
"description": "{scan_note}",
|
||||
"data": {
|
||||
"intent_candidate": "Intent to register"
|
||||
},
|
||||
"data_description": {
|
||||
"intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_intent": {
|
||||
"name": "Send intent",
|
||||
"description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.",
|
||||
"fields": {
|
||||
"intent": {
|
||||
"name": "Intent",
|
||||
"description": "Which registered intent to send, by the name the Intents screen shows."
|
||||
},
|
||||
"variables": {
|
||||
"name": "Variables",
|
||||
"description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty — nothing was sent."
|
||||
},
|
||||
"unsupported_target": {
|
||||
"title": "Unsupported entity for this action",
|
||||
"message": "{entity_id} is not a participant of {service} — nothing was 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}"
|
||||
},
|
||||
"no_target": {
|
||||
"title": "No Shield selected",
|
||||
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
|
||||
},
|
||||
"ambiguous_target": {
|
||||
"title": "More than one Shield selected",
|
||||
"message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent."
|
||||
},
|
||||
"unknown_activity": {
|
||||
"title": "Unknown activity",
|
||||
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
|
||||
},
|
||||
"activity_launch_failed": {
|
||||
"title": "Activity could not launch",
|
||||
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
|
||||
},
|
||||
"protocol_unavailable": {
|
||||
"title": "Remote-protocol transport unavailable",
|
||||
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
|
||||
},
|
||||
"unknown_intent": {
|
||||
"title": "Unknown intent",
|
||||
"message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents."
|
||||
},
|
||||
"missing_intent_field": {
|
||||
"title": "Missing variable",
|
||||
"message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent."
|
||||
},
|
||||
"unknown_intent_variable": {
|
||||
"title": "Undeclared variable",
|
||||
"message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables."
|
||||
},
|
||||
"invalid_intent_field": {
|
||||
"title": "Wrong value type",
|
||||
"message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent."
|
||||
},
|
||||
"invalid_intent_variables": {
|
||||
"title": "Variables must be a mapping",
|
||||
"message": "'variables' must map field names to values for '{intent}' — nothing was sent."
|
||||
},
|
||||
"unresolved_intent_placeholder": {
|
||||
"title": "Unfilled placeholder",
|
||||
"message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent."
|
||||
},
|
||||
"intents_need_adb": {
|
||||
"title": "ADB is required for intents",
|
||||
"message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent."
|
||||
},
|
||||
"intent_no_answer": {
|
||||
"title": "The Shield did not answer",
|
||||
"message": "The Shield did not answer for {entity_id} — the intent was not sent."
|
||||
},
|
||||
"intent_failed": {
|
||||
"title": "The Shield refused the intent",
|
||||
"message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_app": {
|
||||
"name": "Current app"
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
},
|
||||
"installed_apps": {
|
||||
"name": "Installed apps"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"screen_capture": {
|
||||
"name": "Screen capture"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,194 @@
|
||||
# Intent catalogue and registered actions (plan)
|
||||
|
||||
Scope: let the user discover the Android **intents** an installed app advertises, register
|
||||
the ones worth having, and fire them from Home Assistant — one button per registered
|
||||
intent, plus a `send_intent` action for the ones that need inputs.
|
||||
|
||||
Status: design agreed in principle by the user (2026-10-12). Not built. Companion docs:
|
||||
`docs/MVP_PLAN.md` (architecture), `docs/RESEARCH_SHIELD.md` (device captures),
|
||||
`docs/RESEARCH_IDEAS.md` (adjacent ideas).
|
||||
|
||||
**Decisions locked (user):**
|
||||
|
||||
- Registered **buttons**, set up inside our options menu, plus a **`send_intent`** action.
|
||||
Verbatim: *"The registered buttons, pre-setup within our menu options and a send_intent
|
||||
command seem the best method here. Less messy than the current adb command method of
|
||||
doing that."*
|
||||
- Discovery is **explicit** (the user scans an app), never an automatic sweep.
|
||||
- YAML stays **seed-only** (the standing ruling: we load it only when no UI config exists).
|
||||
|
||||
---
|
||||
|
||||
## 1. Where we are today
|
||||
|
||||
| Surface | State |
|
||||
|---|---|
|
||||
| `media_player.play_media` with a URL | Works — sends a `VIEW` intent through the ADB path |
|
||||
| `nvidia_shield.adb_command` | Works but is a raw escape hatch, and until t14 cannot even be aimed at the Shield from the UI |
|
||||
| Intent **discovery** | Does not exist |
|
||||
| Intent **registration** | Does not exist |
|
||||
| `ha_adb_common/intent.py` | **Not Android intents** — it is `PlaybackIntent`, the optimistic-UI dataclass. The name collision is a readability trap worth fixing (§7.4) |
|
||||
|
||||
So the user's memory of "scanning the available commands for ADB between applications" is
|
||||
a real gap, not something built and forgotten.
|
||||
|
||||
### What the device actually exposes (observed, one Shield, Android 11)
|
||||
|
||||
From the user's capture of `pm dump com.netflix.ninja` on 2026-10-12:
|
||||
|
||||
- the output is ~**2241 lines** and is a *whole* dumpsys — `DUMP OF SERVICE package:`,
|
||||
then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`;
|
||||
- the package section begins with an **`Activity Resolver Table:`** listing, per
|
||||
component/filter, the `Action:`, `Category:`, `Scheme:`, `Authority:` and `Path:` lines,
|
||||
followed by `Receiver Resolver Table:` and `Service Resolver Table:`;
|
||||
- Netflix's table contains real, usable entries (`cast:`, `http`/`https` with
|
||||
`www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` + `LEANBACK_LAUNCHER`) and a
|
||||
large amount of internal noise (`com.netflix.action.DEBUG_*`, `androidx.*`,
|
||||
`com.google.firebase.*`, `ConstraintProxy*`).
|
||||
|
||||
Everything in §2 depends on that shape. It is **observed on one firmware**, so the parser
|
||||
must degrade honestly rather than assume.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discovery
|
||||
|
||||
Source of truth: the app's **activity resolver table**.
|
||||
|
||||
Filter **on the device** — on this Shield an unfiltered `pm dump` ships 2241 lines over
|
||||
ADB for one app:
|
||||
|
||||
```sh
|
||||
pm dump <pkg> | sed -n '/Activity Resolver Table/,/Service Resolver Table/p'
|
||||
```
|
||||
|
||||
with a bounded `grep -A` fallback when the device's `sed`/toolbox cannot do ranges. Keep
|
||||
the transfer to the resolver section; the rest of the dump is never needed.
|
||||
|
||||
Rules:
|
||||
|
||||
- **One package at a time, on demand**, triggered by the user from the Intents sub-screen.
|
||||
Never a sweep of every installed app (that is 20 × ~2241 lines on this device).
|
||||
- **Cached** per package with a TTL, in the same spirit as the app-label cache; a scan
|
||||
that *ran* and found nothing is a real answer, a scan whose transport failed is not
|
||||
(the lesson from the discovery-cache defect).
|
||||
- **Rank, do not hide.** Flag internal-looking filters (`DEBUG_*`, `androidx.*`,
|
||||
`com.google.firebase.*`) rather than deleting them — the user decides what is worth
|
||||
registering, and we do not get to guess.
|
||||
|
||||
A cheaper action-first query also exists (`cmd package query-activities --brief -a …`).
|
||||
**Not verified on this device**; treat as a possible optimisation, not the primary path.
|
||||
|
||||
Candidates are deduplicated on (component, action, categories, scheme/authority/path).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model — a registered intent
|
||||
|
||||
```yaml
|
||||
registered_intents:
|
||||
- id: netflix_title # slug; also the button's key
|
||||
name: Netflix — open title # what the user sees
|
||||
package: com.netflix.ninja
|
||||
activity: .MainActivity # either a component…
|
||||
action: android.intent.action.VIEW # …or an action (+ categories)
|
||||
categories: [android.intent.category.DEFAULT]
|
||||
data: "https://www.netflix.com/watch/{id}" # URI template, optional
|
||||
fields: # declared inputs, with defaults
|
||||
- {key: id, type: string, required: true}
|
||||
```
|
||||
|
||||
Mapping to `am start` (through the existing ADB path):
|
||||
|
||||
| Declared | `am start` |
|
||||
|---|---|
|
||||
| component | `-n com.netflix.ninja/.MainActivity` |
|
||||
| action | `-a android.intent.action.VIEW` |
|
||||
| categories | `-c …` (repeated) |
|
||||
| data template | `-d <resolved>` |
|
||||
| `type: string` | `--es key value` |
|
||||
| `type: int` | `--ei key value` |
|
||||
| `type: bool` | `--ez key value` |
|
||||
|
||||
Notes that matter:
|
||||
|
||||
- **No eval, no shell interpolation.** `{placeholder}` substitution is plain string
|
||||
replacement, and every value is `shlex.quote`d before it reaches the device.
|
||||
- A missing **required** field is a `ServiceValidationError` naming the field.
|
||||
- An optional `raw` field type passes extras verbatim for advanced cases, documented as
|
||||
**unvalidated** — the escape hatch, not the default.
|
||||
|
||||
---
|
||||
|
||||
## 4. Surfaces
|
||||
|
||||
1. **One button entity per registered intent** — `button.<name>`, pressing it runs the
|
||||
intent with the declared defaults. This is the "register an action for each one" the
|
||||
user asked for, and it is the whole point for no-input intents.
|
||||
2. **`nvidia_shield.send_intent`** — `intent: <name>` (a dropdown of registered intents)
|
||||
plus `variables: {…}` supplying that intent's declared fields. This is how per-intent
|
||||
inputs arrive from automations, scripts and dashboards; it mirrors `script.turn_on`
|
||||
with variables because HA offers no clean way to register a service per config entry.
|
||||
The action **returns the device's `am start` output**, so a failure is visible.
|
||||
3. **Intents sub-screen** in the options flow, the same idiom as the Apps screen:
|
||||
*Add an intent* (pick package → pick a discovered candidate or enter one manually →
|
||||
name it → declare its fields), *Edit*, *Remove*, *Scan an app's intents*.
|
||||
4. **YAML seeding** — a `registered_intents:` block, honoured only when the UI holds
|
||||
nothing (seed-only ruling).
|
||||
|
||||
---
|
||||
|
||||
## 5. Failure modes and honesty
|
||||
|
||||
- `am start` failures (unresolved intent, permission denied, activity gone after an app
|
||||
update) **surface the device's own message** and raise; never a silent success.
|
||||
- **ADB is required.** The protocol transport cannot send intents; when ADB is down the
|
||||
error says so instead of failing obscurely.
|
||||
- An unknown intent name raises, listing the known names.
|
||||
- An intent that the app rejects is a **config** problem, and the docs say so plainly.
|
||||
- Nothing registered ever runs by itself, and discovery never auto-registers anything.
|
||||
|
||||
---
|
||||
|
||||
## 6. Costs
|
||||
|
||||
- Discovery: one filtered dump per scanned package, cached.
|
||||
- Invocation: one ADB shell per press.
|
||||
- Storage: registered intents are ordinary options data.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions
|
||||
|
||||
1. Does `send_intent` returning a response need `SupportsResponse.OPTIONAL` on our HA
|
||||
floor? Verify against the real installed HA before building on it.
|
||||
2. Typed fields (string/int/bool) for v1, or string-plus-raw only? Recommendation: typed,
|
||||
since that is what makes the dropdown-plus-variables model pleasant.
|
||||
3. Broadcasts/receivers (`am broadcast`) — the tables are right there — are **out of scope
|
||||
for v1**; activities and actions only.
|
||||
4. Rename `ha_adb_common/intent.py` → `playback_intent.py` so the new `intents.py` is not
|
||||
confused with it? Small churn, real clarity gain.
|
||||
5. Should discovery be exposed as a service as well as a UI screen? Recommendation: UI
|
||||
only in v1.
|
||||
6. `play_media(media_content_type="intent")`? Recommendation: **no** — the button and the
|
||||
action are clearer, and play_media already carries the URL/VIEW case.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed tickets
|
||||
|
||||
| Unit | Contents |
|
||||
|---|---|
|
||||
| Discovery | `ha_adb_common/intents.py` + the filtered on-device dump + parser + cache + tests, with a fixture cut from the user's real Netflix capture |
|
||||
| Registration & invocation | Intents sub-screen, per-intent fields, button entities, `send_intent` action, mapping, docs, strings, tests |
|
||||
| Review | Adversarial verification of both, including a rejected-intent and an ADB-down path |
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-goals
|
||||
|
||||
- No automatic intent execution or auto-registration from discovery.
|
||||
- No arbitrary shell passthrough in `send_intent` (`adb_command` remains that door).
|
||||
- No broadcasts/receivers in v1.
|
||||
- No extras beyond string/int/bool/raw.
|
||||
- Not a replacement for the app source list or `play_media`.
|
||||
@@ -0,0 +1,529 @@
|
||||
# ha-nvidia-shield MVP Plan (Shield-only fork)
|
||||
|
||||
Scope: Shield-only. Primary state = ADB media session tracking (`dumpsys media_session`),
|
||||
persistent event-driven connection, with a fallback path for apps that don't publish
|
||||
media sessions. ADB pairing flow stays intact. Companion doc: `docs/RESEARCH_SHIELD.md`
|
||||
(firmware research results, fixtures, and the user's current YAML glue).
|
||||
|
||||
**Decisions locked:**
|
||||
- Option 1 adopted: absorb the remote protocol (`androidtvremote2`) — all-in-one device.
|
||||
- TiviMate (`ar.tvplayer.tv`) publishes no media session → headline fallback app.
|
||||
- `play_media(channel)` replicates core androidtv_remote behavior exactly
|
||||
(digits, 0.1 s delay, no ENTER).
|
||||
|
||||
---
|
||||
|
||||
## 1. Current state assessment
|
||||
|
||||
### Architecture as found
|
||||
|
||||
```
|
||||
config_flow.py ── user step → adb_pair step (one-shot connect test)
|
||||
__init__.py ── no runtime object; hass.data[DOMAIN][entry_id] = entry (nothing!)
|
||||
adb_session.py ── AdbMediaSessionPoller = AdbClientBase + poll() (thin)
|
||||
media_player.py ── the real "coordinator": entity owns ADB socket, polling loop,
|
||||
intent tracking, state tiering, Jellyfin/media-info, power logic
|
||||
sensor.py ── 2 read-only mirror sensors
|
||||
button.py ── Pair ADB (throwaway poller, connect→disconnect→reload)
|
||||
jellyfin.py ── piggybacks on HA core Jellyfin coordinator cache (zero extra HTTP)
|
||||
power.py ── sibling-sensor discovery for the smart plug
|
||||
ha_adb_common/ ── vendored: adb_client (connect/shell/keyevents/media_session poll),
|
||||
media_session parser, running_apps, intent dataclass, keys manager
|
||||
```
|
||||
|
||||
There is **no DataUpdateCoordinator and no shared runtime object**. The entity
|
||||
(`NvidiaShieldMediaPlayer`) *is* the coordinator: it creates the poller in
|
||||
`async_added_to_hass`, runs `async_call_later` polling, and writes state itself.
|
||||
|
||||
### What already works (keep)
|
||||
|
||||
| Area | File | Notes |
|
||||
|---|---|---|
|
||||
| ADB key lifecycle: generate, migrate from legacy paths, namespace per domain | `ha_adb_common/keys.py` | Solid; pairing-flow compatible. Keep untouched. |
|
||||
| Pairing UX: config-flow step + Pair ADB button + "approve on Shield" guidance | `config_flow.py`, `button.py` | Works; keep intact per constraint. |
|
||||
| ADB connect via `adb_shell` (`AdbDeviceTcpAsync` + RSA signer in executor) | `adb_client.py` | Correct executor usage; blocking I/O off the loop. |
|
||||
| Media session parsing: package/active/state/metadata from `dumpsys media_session`; foreground-app preference; playing>paused ordering | `media_session.py` | Works on Shield Android 11; version-drift risk (see fragility). |
|
||||
| Optimistic UI: `PlaybackIntent` + self-heal + one retry + external-override detection | `intent.py`, `media_player.py` | Good design; survives rewrite. |
|
||||
| State tiering: power → intent → Jellyfin → ADB session → ON | `media_player.py` `state` property | Sound priority model. |
|
||||
| Jellyfin metadata piggyback (resolution/HDR/audio labels from core coordinator cache) | `jellyfin.py` | Zero-cost; keep. |
|
||||
| Power: ADB wake as authority, plug "off" as hard off, sibling sensor discovery | `power.py`, `media_player.py` | Keep. |
|
||||
| Running apps from `dumpsys activity recents` | `adb_client.py`, `running_apps.py` | Useful for Media DAG switcher. |
|
||||
| Media commands: ADB keyevent when connected & Jellyfin inactive, else androidtv_remote | `media_player.py` `_media_cmd` | Sensible dual path; superseded by protocol-first routing in Phase 2. |
|
||||
|
||||
### What is fragile
|
||||
|
||||
1. **Connection ownership is in the entity.** The ADB socket, its poll timer and all
|
||||
state live on `NvidiaShieldMediaPlayer`. Reload (options change triggers full
|
||||
entry reload via `_async_update_listener`) tears down and re-handshakes the ADB
|
||||
connection. Sensors have no access to ADB data. A second entity is impossible.
|
||||
2. **Untracked in-flight poll tasks.** `_adb_poll` is dispatched via
|
||||
`async_create_task` and never cancelled on unload; an in-flight `shell()` after
|
||||
`disconnect()` hits `shell()`'s auto-reconnect → surprise reconnect after removal.
|
||||
3. **Silent failure mode.** `shell()` swallows errors at DEBUG, returns `None`, marks
|
||||
disconnected, and the next poll is 300 s later. Meanwhile `self._adb_awake` and
|
||||
`last_info` keep their last values — the entity can report `playing` for minutes
|
||||
while the device is asleep/dead. **No staleness/expiry on media session info.**
|
||||
4. **Wake detection is a substring.** `"Awake" in dumpsys power result` — works on
|
||||
this firmware (`mWakefulness=Awake/Asleep`, verified R5) but fragile against
|
||||
future values like `Dozing`; switch to exact value match.
|
||||
5. **Parser depends on exact dumpsys text** (`package=`, `active=true`,
|
||||
`state=PlaybackState` `state=3/2`, `description=` positional split) and `head -200`
|
||||
truncates the dump — sessions below the cut are lost. No position parsed from
|
||||
`PlaybackState` despite it being present (verified R9: position/ms + uptime-ms
|
||||
`updated`).
|
||||
6. **Intent retry sends the wrong path.** `_on_intent_timeout` retries via
|
||||
`_cmd()` (androidtv_remote) even when the original command succeeded via ADB.
|
||||
7. **Dead/duplicated logic.** `jf_active` computed and unused in `_adb_poll`;
|
||||
`_resolve_adb_host` duplicated in 3 places; `JELLYFIN_APP_IDS` defined but never
|
||||
used (Wholphin routing); Current App sensor merely mirrors androidtv attributes.
|
||||
8. **State writes gated on change.** `_adb_poll` writes HA state only when wake state
|
||||
flipped or `last_info` exists — `running_apps` changes and info *clears* can be
|
||||
missed; stale attribute windows.
|
||||
9. **`power is False → IDLE` not OFF.** ADB-asleep maps to IDLE (standby) by design,
|
||||
but the plug-off check happens before ADB, so semantics are split across two
|
||||
places and easy to break.
|
||||
10. **No tests, no CI lint gate** (ruff/pytest configured in `pyproject.toml` but
|
||||
zero test files). A rewrite will be blind.
|
||||
11. `iot_class: local_polling` + per-entity 10 s polling of **three** dumpsys
|
||||
commands (power, media_session, activity recents) — wasted round trips when
|
||||
nothing changed.
|
||||
|
||||
### What's missing for MVP
|
||||
|
||||
- Persistent connection manager with watchdog + backoff (nothing tracks connection
|
||||
health; the only "watchdog" is the next poll).
|
||||
- Event-driven trigger source (foreground-app change events) — today everything is
|
||||
timer-driven.
|
||||
- Fallback foreground/state path for apps without media sessions (TiviMate is the
|
||||
headline case — verified no session, R2).
|
||||
- Playback position from media sessions (+ uptime-anchored math, R9).
|
||||
- Media session staleness handling (session gone → transition to idle).
|
||||
- Tests + CI (lint + unit) to make the rewrite safe.
|
||||
|
||||
---
|
||||
|
||||
## 2. Event-driven rewrite: keep or replace the polling layer?
|
||||
|
||||
**Decision: replace the per-entity poll loop with an entry-scoped
|
||||
`ShieldConnectionManager` that runs a hybrid event/heartbeat loop. Do not try to
|
||||
make logcat a true push stream over `adb_shell` for MVP.**
|
||||
|
||||
### Why not pure logcat push (yet)
|
||||
|
||||
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)
|
||||
|
||||
```
|
||||
entry.runtime_data = ShieldConnectionManager
|
||||
├─ transport A: androidtvremote2 protocol client (Phase 2)
|
||||
│ push callbacks: is_on, is_available, current_app, volume_info
|
||||
│ commands: send_key_command, send_launch_app_command, IME
|
||||
├─ 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
|
||||
├─ 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)
|
||||
├─ parsed StateStore: {is_on, foreground_pkg, sessions[], audio_playback[],
|
||||
│ last_updated ts per field}
|
||||
└─ event API: listener registration so entities are thin views
|
||||
```
|
||||
|
||||
Net effect vs today: one persistent socket instead of reconnect-on-failure;
|
||||
wake-ups driven by device events within seconds; heartbeat as fallback; wake check
|
||||
folded into the foreground query instead of a separate grep; per-cycle cost drops
|
||||
from 3 dumpsys commands × every 10 s to ~1 command × on-event + 1 × per heartbeat.
|
||||
|
||||
### Blocking issues to fix before/during the rewrite (flagged)
|
||||
|
||||
| # | Blocker | Where | Fix |
|
||||
|---|---|---|---|
|
||||
| B1 | Connection owned by entity; no shared runtime object | `media_player.py` `_setup_adb`, `__init__.py` | Move connection+state into `ShieldConnectionManager` stored in `entry.runtime_data`; entities subscribe. Mirror HA core androidtv's `entry.runtime_data` dataclass pattern. |
|
||||
| B2 | Untracked `async_create_task` polls escape unload | `media_player.py` `_adb_poll_callback` | Manager owns its tasks; cancel on `async_stop` + `EVENT_HOMEASSISTANT_STOP`; guard `shell()` reconnect behind explicit lifecycle calls. |
|
||||
| B3 | `shell()` auto-reconnect hides failure from callers | `adb_client.py` `shell` | Explicit error propagation; reconnect handled only by watchdog. Adopt androidtv core's `adb_decorator` shape: catch known ADB exceptions → log once → close → mark unavailable. |
|
||||
| B4 | `head -200` truncation + parser text-coupling | `adb_client.py`, `media_session.py` | Remove `head`; tolerant parser w/ real firmware fixtures (captured — `docs/RESEARCH_SHIELD.md`). |
|
||||
| B5 | Stale-state window (no TTL on `last_info`/`_adb_awake`) | `media_player.py` | StateStore carries timestamps; info older than TTL (e.g. 2×heartbeat) is expired. |
|
||||
| B6 | Options change reloads entry → full reconnect churn | `__init__.py` update listener | Adopt core androidtv's pattern: only reload for structural options (`RELOAD_OPTIONS`), otherwise re-read options via `async_dispatcher_send`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Fallback mechanism for apps without media sessions
|
||||
|
||||
Options considered:
|
||||
|
||||
| Option | Reliability | Cost | Notes |
|
||||
|---|---|---|---|
|
||||
| **A. Foreground app via `dumpsys activity activities` (`mResumedActivity` / `topResumedActivity`)** | High on Shield (Android 11/12 line) | 1 cheap command, on-event only | Deterministic current app; works for *every* app. Verified on this Shield (R4): `mResumedActivity` is the Android-11 pattern; `topResumedActivity` kept as newer-firmware fallback. |
|
||||
| B. Intent tracking (Android usage/intent logs) | Low–medium | Higher parse cost | Requires AccessibilityService/app instrumentation on modern Android — not viable via ADB shell. Reject. |
|
||||
| C. Last-known-state cache (retain last session per package with timestamp) | Medium (supplementary) | Memory only | No extra polling; answers "what was this app doing". |
|
||||
| D. logcat foreground listener | High for events | Part of the listener work | Covered by the hybrid loop above; R8 verified `ActivityTaskManager: START` lines. |
|
||||
| **E. "Audio playback" section of `dumpsys media_session`** | High as a *hint* | Zero (same command) | NEW from R2: lists recently-playing audio packages even with no session (`ar.tvplayer.tv` shows there). Confidence check that a foreground app is actually playing media. |
|
||||
|
||||
**Recommendation: build A + C — with the Option-1 decision (§9), the absorbed
|
||||
protocol client's `current_app` push is the *primary* foreground signal.** A
|
||||
(`mResumedActivity` dumpsys) is the fallback when the protocol client is down
|
||||
or silent; C (last-known-state cache) turns "foreground but no session" from
|
||||
UNKNOWN into "in <app> (idle / last known paused)"; E enriches the fallback tier
|
||||
with a "media recently active" hint at zero round-trip cost. D lands as the
|
||||
ADB-side trigger in Phase 1.
|
||||
|
||||
Resulting state ladder for MVP: protocol is_on → intent → Jellyfin → media
|
||||
session → foreground fallback (`current_app` push, then ADB `mResumedActivity`;
|
||||
state = "on, in app X", with last-known metadata if any) → ON. Audio-playback
|
||||
identity (E) enriches the fallback tier with a "media recently active" hint.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased MVP plan
|
||||
|
||||
### Phase 0 — Stabilize & trim (foundation; ~1–2 days)
|
||||
|
||||
Goal: make the codebase safe to rewrite. No behavior change.
|
||||
|
||||
Files:
|
||||
- `custom_components/nvidia_shield/__init__.py` — introduce `entry.runtime_data`
|
||||
placeholder (shared per-entry context object, initially just config mirror).
|
||||
- `custom_components/nvidia_shield/const.py` — add TTL/staleness constants,
|
||||
backoff constants; wire the currently-unused `JELLYFIN_APP_IDS` and add
|
||||
`com.github.damontecres.wholphin` + `ar.tvplayer.tv` package constants.
|
||||
- `custom_components/nvidia_shield/adb_session.py` + `ha_adb_common/adb_client.py`
|
||||
— de-duplicate host resolution into one helper; make `shell()` propagate errors
|
||||
(B3) while keeping current callers working; stop swallowing poll errors silently.
|
||||
- `custom_components/nvidia_shield/media_player.py` — remove dead `jf_active`;
|
||||
fix intent retry to use the path that originally succeeded; track+cancel poll
|
||||
tasks on unload (B2); write state whenever any ADB-derived value changes.
|
||||
- `tests/` (new) — parser fixtures (real `dumpsys media_session`, `activity
|
||||
recents` outputs), intent-logic unit tests, config-flow smoke tests. Real
|
||||
firmware fixtures are already captured — see the fixtures table in
|
||||
`docs/RESEARCH_SHIELD.md`.
|
||||
- `pyproject.toml`/CI — run ruff + pytest on PR.
|
||||
|
||||
Risks: parser fixture drift across Shield firmware (mitigate: fixtures from 2+
|
||||
firmware versions, tolerant regexes). Fixing `shell()` error propagation can expose
|
||||
previously hidden reconnect bugs — expected, ship together with the task-tracking
|
||||
fix.
|
||||
|
||||
Done when: all tests green; no in-flight task survives entity unload (test
|
||||
proves it); intent retry honors command path; ruff clean.
|
||||
|
||||
### Phase 1 — Core playback state + connection reliability (~3–5 days)
|
||||
|
||||
Goal: reliable, fast, persistent connection; media session is the source of truth.
|
||||
|
||||
Files:
|
||||
- `custom_components/nvidia_shield/connection.py` (new) — `ShieldConnectionManager`:
|
||||
persistent socket, watchdog + backoff reconnect, on-demand fetch API, StateStore
|
||||
with timestamps, listener registration. API designed to host a second transport
|
||||
(the remote protocol joins in Phase 2).
|
||||
- `custom_components/nvidia_shield/adb_session.py` — becomes thin adapter over the
|
||||
manager (keep `ensure_adb_key` re-export for config flow/button).
|
||||
- `ha_adb_common/adb_client.py` — extract protocol-level commands (no loop
|
||||
scheduling); add explicit `health_check()`.
|
||||
- `ha_adb_common/media_session.py` — parse `PlaybackState` position (ms, with
|
||||
uptime-anchored math — `updated` is uptime-ms per R9); add session-expiry at
|
||||
store level.
|
||||
- `media_player.py`, `sensor.py` — entities become views over StateStore;
|
||||
state tiering unchanged; availability reflects manager connection health
|
||||
(`_attr_available`).
|
||||
- `manifest.json` — `iot_class: local_polling` stays until the protocol client
|
||||
(push) lands in Phase 2, then becomes `local_push`.
|
||||
- `config_flow.py` — pairing step reuses manager's test-connect (no behavior
|
||||
change to flow).
|
||||
|
||||
Risks: `adb_shell` async stability over long-lived connections (mitigate:
|
||||
watchdog pings + teardown/rebuild on protocol errors); reconnect storms during
|
||||
device reboots (backoff + jitter); intent logic regression (covered by Phase-0
|
||||
tests).
|
||||
|
||||
Done when: device reboot recovers automatically within backoff window; play/pause
|
||||
reflected in ≤1 heartbeat cycle (≤30 s) worst case, foreground-triggered changes
|
||||
faster; unavailable/available transitions correct; no state staleness beyond TTL;
|
||||
tests for manager (connect/reconnect/expiry) pass.
|
||||
|
||||
### Phase 2 — All-in-one: absorb the remote protocol (androidtvremote2) (~4–6 days)
|
||||
|
||||
Goal: one integration, one device. We own the Google remote protocol for
|
||||
commands, power and availability; ADB remains the playback-state source. The
|
||||
external androidtv / androidtv_remote integrations are no longer required.
|
||||
|
||||
Files:
|
||||
- `manifest.json` — add `androidtvremote2` requirement (`adb-shell` stays).
|
||||
- `config_flow.py` — protocol pairing ported from core androidtv_remote: generate
|
||||
cert if missing, fetch name + MAC (unique_id), PIN pair step (code shown on the
|
||||
Shield), reauth flow on InvalidAuth (device hard reset). The existing ADB
|
||||
pairing step stays intact — both pairings coexist in one flow.
|
||||
- `connection.py` — manager now hosts both transports: protocol client
|
||||
(`keep_reconnecting`, callbacks: is_on, is_available, current_app, volume_info)
|
||||
+ ADB client; one StateStore fed by both.
|
||||
- `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 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.
|
||||
- `__init__.py` — PLATFORMS gains REMOTE; entry migration (VERSION 2); close
|
||||
both clients on HA stop / unload.
|
||||
|
||||
Risks: two pairing experiences (ADB + PIN) need clear flow copy;
|
||||
androidtvremote2's own reconnect task and our ADB watchdog must not fight each
|
||||
other — one owner per transport; existing installs that wrap the external
|
||||
androidtv_remote entity need a migration path (transitional fallback while it's
|
||||
still configured); IME option defaults off.
|
||||
|
||||
Done when: entry sets up standalone (no androidtv/androidtv_remote entries
|
||||
needed); remote entity sends keys and launches apps; real volume level/mute
|
||||
shown; channel selection works via protocol with ADB fallback; device hard reset
|
||||
triggers reauth; ADB pairing unchanged; migration covered by tests.
|
||||
|
||||
### Phase 3 — Fallback app state (~2–3 days, smaller now)
|
||||
|
||||
Goal: every app gets a sensible state even without a media session. The protocol
|
||||
client's `current_app` push is the primary foreground signal; ADB fills gaps.
|
||||
|
||||
Files:
|
||||
- `ha_adb_common/adb_client.py` (or new `foreground.py`) — `mResumedActivity`
|
||||
parser (Android 11 primary pattern; `topResumedActivity` kept as a
|
||||
newer-firmware fallback) — used only when protocol current_app is
|
||||
None/unknown/stale. Do not parse `dumpsys window` focus first (multiple
|
||||
`mCurrentFocus` lines incl. literal `null` — R4).
|
||||
- `connection.py` — foreground priority in StateStore: protocol current_app push
|
||||
> ADB `mResumedActivity`; last-known-state cache per package (option C);
|
||||
audio-playback identity parsed from the same media_session dump (E).
|
||||
- `ha_adb_common/media_session.py` — parse the "Audio playback (lastly played
|
||||
comes first)" section into `{package: last_played_hint}`; ignore-list
|
||||
`com.google.android.apps.mediashell` (Chromecast receiver process).
|
||||
- `media_player.py` — state ladder gains the foreground tier; running_apps
|
||||
enrichment with foreground/last-known info.
|
||||
- `sensor.py` — Current App sensor reads StateStore (protocol push makes it
|
||||
accurate without androidtv attribute reads).
|
||||
- README — document the state ladder and fallback semantics; document the
|
||||
migration away from mediasession2mqtt sensors + the universal-player YAML
|
||||
glue (appendix in `docs/RESEARCH_SHIELD.md`).
|
||||
|
||||
Risks: dumpsys format differences across Shield Experience releases (fixtures
|
||||
from real devices; R4); apps that keep background sessions while another is in
|
||||
the foreground (session-vs-foreground preference already handles); last-known
|
||||
cache must never resurrect stale "playing".
|
||||
|
||||
Done when: launching an app with no media session shows correct app + "on/idle"
|
||||
within one update cycle; returning to an app with a session shows its state;
|
||||
state tracks `current_app` push with no attribute polling; stale cache never
|
||||
reports playing after >TTL; **`play_media` (`media_content_type: "channel"`)
|
||||
selects TiviMate channels by number (protocol path, ADB fallback) per §7**;
|
||||
tests cover all ladder branches.
|
||||
|
||||
### Phase 4 — QoL (~ongoing)
|
||||
|
||||
Files:
|
||||
- Deep links — now cheap: `media_type: app/url` → protocol
|
||||
`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; 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: 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.
|
||||
|
||||
Risks: deep-link semantics differ per app (intent extras) — keep the app map
|
||||
data-driven; options migration bugs (test entry migration).
|
||||
|
||||
Done when: deep links work for 2–3 reference apps; apps editor works; discovery
|
||||
updates host on IP change; docs updated.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependency & compatibility notes
|
||||
|
||||
- Runtime deps: `adb-shell>=0.4.0` + `androidtvremote2` (adopted in Phase 2 —
|
||||
small, HA-core-blessed); `zeroconf` integration dependency joins in Phase 4
|
||||
for discovery. Logcat *streaming*
|
||||
is the only thing that might eventually
|
||||
need internals or a new dep — explicitly deferred and flagged.
|
||||
- Vendored `ha_adb_common` stays for HACS; keep the hisense_tv consumer in mind
|
||||
when changing its API (add, don't break; the fork can diverge later but Phase 0–2
|
||||
changes should remain additive where possible).
|
||||
- Pairing flow: ADB pairing (`async_step_adb_pair`, Pair ADB button,
|
||||
`ensure_adb_key`, `.storage/nvidia_shield/adbkey` layout) is untouched in all
|
||||
phases. The remote-protocol cert/PIN pairing (`.storage` certs) is *added*
|
||||
alongside it in Phase 2.
|
||||
|
||||
## 6. Sequencing summary
|
||||
|
||||
| Phase | Theme | Primary outcome |
|
||||
|---|---|---|
|
||||
| 0 | Stabilize/trim, tests | Safe rewrite base; no behavior change |
|
||||
| 1 | Persistent connection + session truth | Reliable fast playback state, auto-recovery |
|
||||
| 2 | Absorb remote protocol (androidtvremote2) | All-in-one device; channel select; focused-app push |
|
||||
| 3 | Foreground fallback + last-known state | Full-app coverage without sessions |
|
||||
| 4 | Deep links, apps editor, discovery, diagnostics | Polish |
|
||||
|
||||
---
|
||||
|
||||
## 7. TiviMate channel selection (`play_media` port)
|
||||
|
||||
**Requirement:** retain `media_player.play_media` with `media_content_type: "channel"`
|
||||
so TiviMate channels can be selected by number (e.g. `media_id: "1"`). This logic
|
||||
lives today in the Android TV Remote integration; with Option 1 adopted we port
|
||||
it onto our own protocol client (primary path) with ADB keyevents as fallback.
|
||||
|
||||
### Port design
|
||||
|
||||
- Add `MediaPlayerEntityFeature.PLAY_MEDIA` and implement `async_play_media(media_type, media_id)`.
|
||||
- When `media_type == "channel"`: extract digits from `media_id`, then send each
|
||||
digit as an ADB keyevent — `KEYCODE_0` = 7 … `KEYCODE_9` = 16 (digit n → keycode 7+n) —
|
||||
with a configurable delay between digits (default ~0.5 s), then a confirm
|
||||
keycode. Reference (verified from HA core `androidtv_remote/media_player.py`):
|
||||
core sends digits ONLY — `media_id.isnumeric()`, cancels any prior in-flight
|
||||
channel task (concurrency guard), `delay_secs=0.1`, and never sends a confirm
|
||||
key: the device's channel overlay auto-selects. Port: digit n → keycode 7+n;
|
||||
make ENTER an optional boolean (default off — replicates the androidtv_remote
|
||||
behavior the user confirms works today).
|
||||
- Other `media_type` values (`app`, `url`): launch via the protocol client's
|
||||
`send_launch_app_command` once Phase 2 lands; until then reject unknown types
|
||||
with a log warning.
|
||||
- **Fallback chain:** protocol client down → ADB `input keyevent` digits (keycode
|
||||
7+n, slower but socket-independent) → transitional only: delegate to the
|
||||
still-configured external androidtv_remote entity (migration path, removed in
|
||||
Phase 4). Order: protocol first, ADB second, legacy third.
|
||||
- Command path: protocol `send_key_command` (push-based, fast, no extra shell
|
||||
round trip); ADB keyevents only as the fallback (works whenever the socket is
|
||||
up, regardless of the remote protocol's state).
|
||||
- Timing risk: too-fast digits can be dropped or the overlay can close early —
|
||||
make the inter-digit delay a `keyvalue` option with a safe default (0.1 s,
|
||||
core-verified); the live check (R7) remains optional.
|
||||
- Service-call note: current HA syntax passes `data.media.media_content_id` /
|
||||
`media_content_type`; `async_play_media` receives `"channel"` / `"<n>"` the
|
||||
same as before.
|
||||
|
||||
### Metadata implication
|
||||
|
||||
Channel *metadata* (EPG name, title) for TiviMate is a separate question from
|
||||
channel *selection*:
|
||||
|
||||
- TiviMate: **no media session** (R2 verdict) → now-playing = Phase-3 foreground
|
||||
fallback: state = "on, in TiviMate", title = `last_channel` cache when
|
||||
play_media fires (cheap, honest, no EPG claim). Full EPG/EPG-logo data is out
|
||||
of scope for the ADB-only MVP.
|
||||
- Jellyfin-family clients: **Wholphin (`com.github.damontecres.wholphin`)**
|
||||
publishes a proper session with usable metadata (R9) — routes through
|
||||
`JELLYFIN_APP_IDS` to the rich-metadata path.
|
||||
- Netflix publishes a stale inactive session (`active=false`, `state=null`) —
|
||||
the parser must keep ignoring it; a fixture pins this behavior.
|
||||
|
||||
### Where it lands
|
||||
|
||||
- Phase 2 (the port target is the protocol client absorbed in Phase 2; TiviMate
|
||||
state *display* comes from Phase 3's fallback).
|
||||
|
||||
---
|
||||
|
||||
## 8. Local research on the Shield — STATUS
|
||||
|
||||
**STATUS: DONE.** R1–R5, R8, R9 executed; R6 optional-not-run; R7 skipped by
|
||||
decision. Full results, raw captures, and the user's current YAML glue are in
|
||||
`docs/RESEARCH_SHIELD.md`.
|
||||
|
||||
| Step | Status | Key result |
|
||||
|---|---|---|
|
||||
| Setup | ✅ | Shield `mdarcy`, Android 11, `RQ1A.210105.003`; custom launcher `com.spocky.projengmenu` |
|
||||
| R1 | ✅ | TiviMate package = **`ar.tvplayer.tv`** (plan's `iv.android.tv` guess was wrong) |
|
||||
| R2 | ✅ | **No media session** for TiviMate (branch 3). Netflix stale inactive session only. NEW: "Audio playback" section lists `ar.tvplayer.tv` |
|
||||
| R3 | ⏳ partial | Wholphin + Netflix captured; YouTube/Spotify/Plex optional |
|
||||
| R4 | ✅ | `mResumedActivity` is the Android-11 pattern; no `topResumedActivity`; `mCurrentFocus=null` trap |
|
||||
| R5 | ✅ | `mWakefulness=Awake/Asleep` — switch to exact value match |
|
||||
| R6 | ⏳ | Not run (optional; low risk) |
|
||||
| R7 | ⏭ skipped | Replicate core behavior (digits, 0.1 s, no ENTER) — user's existing flow works |
|
||||
| R8 | ✅ | `ActivityTaskManager: START` lines reliable incl. home button; user has `be.digitalia.mediasession2mqtt` installed |
|
||||
| R9 | ✅ | `PlaybackState{state=3, position (ms), speed, updated (uptime ms)}` parseable; Wholphin session good (`description=Twirlywoos`) |
|
||||
|
||||
---
|
||||
|
||||
## 9. HA core research: androidtv + androidtv_remote (what's worth taking)
|
||||
|
||||
Sources reviewed on `dev`:
|
||||
[androidtv](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv) ·
|
||||
[androidtv_remote](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv_remote).
|
||||
|
||||
Key findings first:
|
||||
|
||||
1. **`androidtv` (ADB component) has NO `async_play_media`** — channel-by-number
|
||||
logic is not there. It lives in **`androidtv_remote/media_player.py`**, so §7's
|
||||
port source is confirmed to be androidtv_remote (not androidtv).
|
||||
2. **Both components ship `PLATFORMS = [MEDIA_PLAYER, REMOTE]`** — HA core's own
|
||||
precedent for one device exposing a media_player + remote entity pair. That is
|
||||
exactly the all-in-one shape requested.
|
||||
3. `androidtv`'s remote entity is pure ADB (`input keyevent`) — a basic remote
|
||||
entity needs no remote-protocol dependency (superseded by the Option-1
|
||||
decision; its logic survives as our command fallback path).
|
||||
|
||||
### Worth taking from `androidtv` (ADB component)
|
||||
|
||||
| Pattern | File | Fold into |
|
||||
|---|---|---|
|
||||
| `entry.runtime_data` dataclass: client + options snapshot | `__init__.py` | Phase 1 manager (B1) — canonical HA pattern to mirror |
|
||||
| `ConfigEntryNotReady` on first connect failure → HA itself retries setup | `__init__.py` | Phase 1 — better than our in-entity 300 s retry |
|
||||
| Close connection on `EVENT_HOMEASSISTANT_STOP` | `__init__.py` | Phase 1 manager lifecycle |
|
||||
| Options change **without full reload**: only reload for `RELOAD_OPTIONS`; otherwise re-read options via `async_dispatcher_send` | `__init__.py` update_listener | Proper fix for B6 (reload churn) |
|
||||
| `adb_decorator`: uniform exception handling → log once, `adb_close()`, mark entity unavailable | `entity.py` | Phase 1 — replaces swallow-at-DEBUG `shell()` (B3) |
|
||||
| Reconnect-on-update + `_failed_connect_count` (log first failure only) | `media_player.py` `async_update` | Phase 1 watchdog detail |
|
||||
| `select_source` = launch app; `!name` prefix = close app; `source_list` built from running apps | `media_player.py` | Phase 3/4 QoL — natural fit with our existing `running_apps` |
|
||||
| `adb_command` service, diagnostics | `services.py`, `diagnostics.py` | Phase 4 QoL |
|
||||
|
||||
### Worth taking from `androidtv_remote` (remote protocol)
|
||||
|
||||
| Pattern | File | Fold into |
|
||||
|---|---|---|
|
||||
| **Channel logic (the §7 port source)**: `media_type == CHANNEL`, `media_id.isnumeric()`, cancel previous in-flight channel `asyncio.Task`, `_send_key_commands(list(media_id), delay_secs=0.1)` — digits only, **no ENTER** | `media_player.py` | §7 |
|
||||
| `app`/`url` media types → launch app; BrowseMedia app directory | `media_player.py` | Phase 4 deep links + browse |
|
||||
| Entities as thin callback-views over one runtime API object (available / is_on / current_app / volume callbacks) | `entity.py` | Phase 1 manager → entity design |
|
||||
| `keep_reconnecting()` + trigger reauth when the device hard-resets (cert invalid) | `__init__.py` | Phase 1/2 watchdog: distinguish "unreachable" (backoff) from "unpaired" (reauth) |
|
||||
| Zeroconf discovery (`_androidtvremote2._tcp`, MAC as unique_id, host auto-update on IP change) | `config_flow.py` | Phase 4 QoL |
|
||||
| Cert+PIN pairing, certs stored in `.storage` | `config_flow.py`, `helpers.py` | Phase 2 |
|
||||
|
||||
### All-in-one: the remote as an entity alongside the media_player — DECIDED
|
||||
|
||||
Today we *wrap* an external `androidtv_remote` entity (resolving its host from
|
||||
its config entry). **Decision: Option 1 adopted** — androidtvremote2 is excellent
|
||||
for *commands* but weak on receiving playback state; our ADB session tracking
|
||||
fills exactly that gap, making the two transports complementary. Phase 2 absorbs
|
||||
the protocol: we own the connection, one device provides `media_player` +
|
||||
`remote`, and the `current_app` push becomes the primary foreground signal —
|
||||
fewer dumpsys round trips and a more reliable Phase-3 fallback. ADB remains the
|
||||
state source; the ADB pairing flow stays intact. The ADB-only remote entity
|
||||
(Option 2) is not built as a separate platform; its `input keyevent` logic
|
||||
survives as the command fallback path inside `async_play_media`/command routing.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,423 @@
|
||||
# Shield firmware research results (§8 of MVP_PLAN.md, R1–R9)
|
||||
|
||||
Captured from the user's Shield. These outputs are the ground truth for parser
|
||||
fixtures and resolved the R2 verdict. Plan deltas applied to `MVP_PLAN.md` are
|
||||
listed at the end.
|
||||
|
||||
## Environment
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Device | NVIDIA Shield (board `mdarcy`) |
|
||||
| Android | 11 (`ro.build.version.release=11`) |
|
||||
| Fingerprint | `NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4231.0265:user/release-keys` |
|
||||
| SDK | 30 (Android 11; the `getprop ro.build.version.sdk` call failed on shell truncation — `etprop: inaccessible or not found` — but Android 11 ⇒ SDK 30) |
|
||||
|
||||
Note: the home launcher is **not** the stock Shield launcher — it is
|
||||
`com.spocky.projengmenu` (Project Eternity menu). Foreground detection must not
|
||||
assume `com.google.android.tv.launcher`.
|
||||
|
||||
---
|
||||
|
||||
## R1 — TiviMate package name: `ar.tvplayer.tv`
|
||||
|
||||
```
|
||||
$ adb shell pm list packages | grep -iE 'tivi|^package:iv'
|
||||
package:ar.tvplayer.tv
|
||||
```
|
||||
|
||||
**Verdict:** TiviMate's package is `ar.tvplayer.tv` — **not** `iv.android.tv` as
|
||||
the plan assumed. All match keys, fixtures, and constants use `ar.tvplayer.tv`.
|
||||
|
||||
---
|
||||
|
||||
## R2 — Does TiviMate publish a media session? **NO** (verdict: branch 3)
|
||||
|
||||
Full `dumpsys media_session` with TiviMate in the foreground playing a live
|
||||
channel (captured twice, before/after zapping):
|
||||
|
||||
```
|
||||
MEDIA SESSION SERVICE (dumpsys media_session)
|
||||
|
||||
9 sessions listeners.
|
||||
Global priority session is null
|
||||
User Records:
|
||||
Record for full_user=0
|
||||
...
|
||||
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
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
|
||||
1. **No TiviMate session block at all.** TiviMate registers no MediaSession —
|
||||
it cannot be reached via the session stack. It is a pure Phase-3 fallback app.
|
||||
2. The only session present is **Netflix's, stale/inactive**: `active=false`,
|
||||
`state=null`, `metadata=null`. The current parser requires `active=true` +
|
||||
metadata, so it correctly ignores it — but this exact dump must become a
|
||||
fixture pinning that behavior ("inactive no-state sessions are ignored").
|
||||
3. The before/after-zap captures are **byte-identical** — zapping channels
|
||||
changes nothing in `dumpsys media_session`.
|
||||
|
||||
### NEW discovery — the "Audio playback" section
|
||||
|
||||
The bottom of `dumpsys media_session` lists **apps that recently played audio**,
|
||||
even with no MediaSession:
|
||||
|
||||
```
|
||||
Audio playback (lastly played comes first)
|
||||
uid=10117 packages=ar.tvplayer.tv
|
||||
uid=10006 packages=com.google.android.apps.mediashell
|
||||
```
|
||||
|
||||
- TiviMate shows up here while playing live TV. This is a **zero-cost
|
||||
supplementary signal**: it comes from the *same* `dumpsys media_session` call
|
||||
the integration already makes — no extra round trip.
|
||||
- Use: confirm "this foreground app actually has/played media" (foreground =
|
||||
TiviMate + recent audio playback ⇒ live TV likely playing). Distinguishes
|
||||
"sitting in TiviMate's settings" from "watching a channel".
|
||||
- Note `com.google.android.apps.mediashell` (Chromecast receiver process) also
|
||||
appears — a parse-time ignore-list entry.
|
||||
|
||||
---
|
||||
|
||||
## R4 — Foreground detection format (Android 11): `mResumedActivity` is the pattern
|
||||
|
||||
```
|
||||
$ adb shell dumpsys activity activities | grep -iE 'resumed'
|
||||
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}
|
||||
```
|
||||
|
||||
```
|
||||
$ adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
|
||||
mCurrentFocus=null
|
||||
mFocusedApp=null
|
||||
mCurrentFocus=Window{980f3f1 u0 ar.tvplayer.tv/ar.tvplayer.tv.ui.MainActivity}
|
||||
mFocusedApp=ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
|
||||
1. **No `topResumedActivity` on this firmware** (that pattern is newer Android).
|
||||
Primary pattern = `mResumedActivity: ActivityRecord{… u0 <pkg>/<activity> …}`.
|
||||
Keep `topResumedActivity` parsing for future-proofing, checked *after*
|
||||
`mResumedActivity`.
|
||||
2. `dumpsys window` contains **multiple** `mCurrentFocus` entries and the first
|
||||
can be literally `mCurrentFocus=null` — a naive first-match parser breaks.
|
||||
`mResumedActivity` is the reliable single source; window/focus parsing is a
|
||||
secondary cross-check at most.
|
||||
3. Package extraction: from the `u0 <pkg>/<activity>` fragment of the
|
||||
ActivityRecord line (handles multi-line wraps in the raw dump — match on the
|
||||
joined block, not per-line).
|
||||
4. Launcher foreground (for the "off/idle in launcher" distinction): with the
|
||||
user's custom launcher focused, `mResumedActivity` shows
|
||||
`com.spocky.projengmenu/.ui.home.MainActivity`.
|
||||
|
||||
---
|
||||
|
||||
## R5 — Wake state: exact value match confirmed
|
||||
|
||||
```
|
||||
Awake: mWakefulness=Awake
|
||||
mWakefulnessChanging=false
|
||||
Asleep: mWakefulness=Asleep
|
||||
mWakefulnessChanging=false
|
||||
```
|
||||
|
||||
**Verdict:** the current `"Awake" in result` substring heuristic happens to
|
||||
work on this firmware, but switch to an exact value parse of the
|
||||
`mWakefulness=` assignment (robust against future values like
|
||||
`mWakefulness=Dozing`).
|
||||
|
||||
---
|
||||
|
||||
## R6 — Recent-tasks: **not run** (optional; low risk)
|
||||
|
||||
Still outstanding if `media_dag_running_apps` needs verification on this
|
||||
firmware.
|
||||
|
||||
---
|
||||
|
||||
## R7 — Channel digit keyevents: **skipped by decision**
|
||||
|
||||
The user already uses `media_player.play_media` with
|
||||
`media_content_type: channel` through the androidtv_remote integration and
|
||||
reports it "works perfectly fine" — so we **replicate core's exact behavior**
|
||||
rather than live-testing keyevent variants:
|
||||
|
||||
- digits only (no confirm/ENTER key), inter-digit delay **0.1 s** (core default),
|
||||
- cancel any prior in-flight channel task before sending,
|
||||
- keep ENTER as an *optional* boolean (default off) in case a future app needs it.
|
||||
|
||||
R7 stays in the doc as an optional verification step if protocol-path digits
|
||||
ever misbehave.
|
||||
|
||||
---
|
||||
|
||||
## R8 — Logcat event reliability: `ActivityTaskManager: START` lines confirmed
|
||||
|
||||
```
|
||||
10-08 06:54:33.748 3693 5205 I ActivityTaskManager: START u0
|
||||
{act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
|
||||
flg=0x10200000 cmp=ar.tvplayer.tv/.ui.MainActivity} from uid 2000
|
||||
|
||||
10-08 06:54:50.439 3693 5212 I ActivityTaskManager: START u0
|
||||
{act=android.intent.action.MAIN
|
||||
cat=[android.intent.category.LEANBACK_LAUNCHER] flg=0x10220000
|
||||
pkg=com.spocky.projengmenu cmp=com.spocky.projengmenu/.ui.home.MainActivity
|
||||
(has extras)} from uid 10059
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
|
||||
1. `ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` lines are emitted on
|
||||
every app open — including the **home button** (which sends a
|
||||
`LEANBACK_LAUNCHER` START for the launcher). App-switch detection via logcat
|
||||
delta snapshots is viable in both directions.
|
||||
2. `WindowManager` lines were absent from the capture — rely on
|
||||
`ActivityTaskManager` first.
|
||||
3. The user has **`be.digitalia.mediasession2mqtt`** installed
|
||||
([MediaSession2MQTT](https://github.com/cbeyls/MediaSession2MQTT)) — a
|
||||
notification-listener service that publishes media sessions over MQTT. It is
|
||||
part of their current glue (see appendix) and is a candidate to retire once
|
||||
our session + fallback path covers its sensors.
|
||||
|
||||
---
|
||||
|
||||
## R9 — PlaybackState layout + a known-good Wholphin session
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**Findings:**
|
||||
|
||||
1. `PlaybackState` carries everything we planned to parse: `state=3` (playing),
|
||||
`position=17237` (ms), `speed=1.0`, `updated=1408392` (**uptime ms**, needs
|
||||
conversion — position math must account for the uptime-vs-wallclock gap;
|
||||
mirror HA's convention by computing `position + (now - updated_time)` with
|
||||
uptime anchoring).
|
||||
2. No duration in PlaybackState — duration lives in metadata keys
|
||||
(`android.media.metadata.DURATION` inside the size=11 metadata dict, beyond
|
||||
the `description=` field the parser currently reads). Parsing the full
|
||||
metadata key list is a Phase-1 stretch goal; `description=` gives
|
||||
title/artist/album positionally as before.
|
||||
3. The session above is **Wholphin** (`com.github.damontecres.wholphin`) — a
|
||||
Jellyfin client that publishes a proper session with usable metadata. Add it
|
||||
to `JELLYFIN_APP_IDS` (that constant is currently defined but **unused** —
|
||||
wire it up) so Jellyfin-family clients route to the rich-metadata path.
|
||||
|
||||
---
|
||||
|
||||
## play_media channel — current working service call (replicate exactly)
|
||||
|
||||
```yaml
|
||||
action: media_player.play_media
|
||||
data:
|
||||
media:
|
||||
media_content_id: '{{ channel_number|int }}'
|
||||
media_content_type: channel
|
||||
metadata: {}
|
||||
target:
|
||||
entity_id:
|
||||
- media_player.shield_android_tv
|
||||
```
|
||||
|
||||
Note the current HA service syntax uses the `media:` block; the entity's
|
||||
`async_play_media(media_type, media_id)` receives `"channel"` / `"<n>"` the same
|
||||
as before.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — the user's current glue this integration replaces
|
||||
|
||||
Recorded as requirements context. This is what works today and what MVP should
|
||||
make unnecessary.
|
||||
|
||||
### Template sensor (app name from the androidtv media_player)
|
||||
|
||||
```yaml
|
||||
template:
|
||||
- triggers:
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
- trigger: state
|
||||
entity_id:
|
||||
- media_player.android_tv_10_50_204_4
|
||||
attribute: app_name
|
||||
sensor:
|
||||
- name: Nvidia Shield App Name
|
||||
unique_id: media_player_nvidia_shield_app_name
|
||||
icon: "mdi:application"
|
||||
state: >
|
||||
{% set adb = "media_player.android_tv_10_50_204_4" %}
|
||||
{% set adb_state = states(adb) %}
|
||||
{% if adb_state not in ['unknown', 'unavailable', 'off'] %}
|
||||
{{ state_attr(adb,'app_name') }}
|
||||
{% elif adb_state in ['off'] %}
|
||||
{{ "off" }}
|
||||
{% else %}
|
||||
{{ "unknown" }}
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Universal media player (state merging + command routing)
|
||||
|
||||
```yaml
|
||||
media_player:
|
||||
platform: universal
|
||||
name: Nvidia Shield
|
||||
device_class: tv
|
||||
unique_id: media_player_nvidia_shield
|
||||
children:
|
||||
- media_player.android_tv_10_50_204_4
|
||||
browse_media_entity: media_player.android_tv_10_50_204_4
|
||||
state_template: >-
|
||||
{% set shield_state = states('media_player.android_tv_10_50_204_4') %}
|
||||
{% set known_app = states('sensor.mediasession2mqtt_2044_application_id') in ["com.google.android.youtube.tv", "com.github.damontecres.wholphin"] %}
|
||||
{% set wholphin = is_state('sensor.nvidia_shield_app_name', 'Wholphin') %}
|
||||
|
||||
{% if(shield_state == "unknown" or shield_state == "unavailable" or shield_state == "off") %}
|
||||
off
|
||||
{% elif wholphin %}
|
||||
{% set playing_state = is_state('binary_sensor.jellyfin_nvidia_shield_playing', 'on') %}
|
||||
{% set paused_state = is_state('binary_sensor.jellyfin_nvidia_shield_paused', 'on') %}
|
||||
{% if paused_state %}
|
||||
paused
|
||||
{% elif playing_state %}
|
||||
playing
|
||||
{% else %}
|
||||
idle
|
||||
{% endif %}
|
||||
{% elif known_app %}
|
||||
{{ states("sensor.mediasession2mqtt_2044_playback_state") }}
|
||||
{% else %}
|
||||
{{ shield_state }}
|
||||
{% endif %}
|
||||
commands:
|
||||
turn_on:
|
||||
action: script.turn_on_nvidia_shield
|
||||
turn_off:
|
||||
action: script.turn_off_nvidia_shield
|
||||
volume_mute:
|
||||
action: switch.toggle
|
||||
target: {entity_id: switch.tv_cabinet_livingroom_speaker_system}
|
||||
volume_up:
|
||||
action: script.volume_up_nvidia_shield
|
||||
volume_down:
|
||||
action: script.volume_down_nvidia_shield
|
||||
volume_set:
|
||||
action: script.volume_set_nvidia_shield
|
||||
data: {level: "{{ volume_level }}"}
|
||||
select_source:
|
||||
action: script.set_specific_app_nvidia_shield
|
||||
data: {app_name: "{{ source }}"}
|
||||
attributes:
|
||||
is_volume_muted: switch.inverse_tv_cabinet_speakers
|
||||
volume_level: media_player.android_tv_10_50_204_4|volume_level
|
||||
source: remote.shield_android_tv|current_activity
|
||||
source_list: remote.shield_android_tv|activity_list
|
||||
```
|
||||
|
||||
### What the MVP replaces vs. keeps
|
||||
|
||||
| Current glue | MVP after Phase 2/3 |
|
||||
|---|---|
|
||||
| `sensor.nvidia_shield_app_name` template sensor | Current App sensor reads StateStore (protocol `current_app` push) |
|
||||
| `mediasession2mqtt` sensors (`*_application_id`, `*_playback_state`) | ADB media sessions + fallback state (retire the MQTT bridge) |
|
||||
| Universal player `state_template` app special-casing | Native state ladder |
|
||||
| `browse_media_entity` from androidtv_remote | Phase 4 BrowseMedia app directory |
|
||||
| `volume_*` scripts (speaker switch + receiver interplay) | **Stays as user automations** — but `volume_set` gains a real protocol path (Phase 2 VOLUME_SET); the receiver/speaker switch logic remains the user's or moves to media zone |
|
||||
| `remote.shield_android_tv` for source/activity | Our own remote entity (Phase 2) |
|
||||
|
||||
---
|
||||
|
||||
## Fixtures to save into `tests/fixtures/`
|
||||
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `dumpsys_media_session_tivimate_playing.txt` | R2 capture 1 (Netflix stale session + audio playback listing) |
|
||||
| `dumpsys_media_session_tivimate_zapped.txt` | R2 capture 2 (identical — pins zap-invariance) |
|
||||
| `dumpsys_media_session_wholphin_playing.txt` | R9 capture (playing session with position/metadata) |
|
||||
| `dumpsys_activity_activities_tivimate.txt` | R4 capture 1 |
|
||||
| `dumpsys_activity_activities_launcher.txt` | R4 capture 2 (custom launcher) |
|
||||
| `dumpsys_window_focused.txt` | R4 window capture |
|
||||
| `dumpsys_power_wakefulness_{awake,asleep}.txt` | R5 captures |
|
||||
| `logcat_activitytaskman_start.txt` | R8 capture |
|
||||
|
||||
---
|
||||
|
||||
## Plan deltas applied to `MVP_PLAN.md`
|
||||
|
||||
1. TiviMate package corrected everywhere: `ar.tvplayer.tv`.
|
||||
2. R2 verdict resolved: no session → TiviMate is the headline Phase-3 fallback
|
||||
app; README gets it as a known case.
|
||||
3. **New fallback signal adopted:** "Audio playback (lastly played comes first)"
|
||||
section of `dumpsys media_session` — zero extra round trip; parsed alongside
|
||||
sessions; `com.google.android.apps.mediashell` in the ignore list.
|
||||
4. Foreground parser: `mResumedActivity` primary (Android 11),
|
||||
`topResumedActivity` newer-fallback; `mCurrentFocus=null` trap documented;
|
||||
launcher special-casing not stock.
|
||||
5. Wake parse: exact `mWakefulness=` value match.
|
||||
6. `play_media(channel)`: replicate core behavior exactly (digits, 0.1 s, no
|
||||
ENTER); R7 marked skipped-by-decision.
|
||||
7. Wholphin (`com.github.damontecres.wholphin`) added to `JELLYFIN_APP_IDS`;
|
||||
wiring that constant becomes a Phase-0/1 item.
|
||||
8. Position math: `updated` is uptime-ms — needs uptime anchoring in Phase 1.
|
||||
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 reading is published as separate top-level `now_playing_*` attributes, and `now_playing_via` names the tier (`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.
|
||||
+5
-8
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ha-nvidia-shield"
|
||||
version = "0.1.1"
|
||||
version = "2026.10.08"
|
||||
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -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,903 @@
|
||||
"""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 datetime
|
||||
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.components.image",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.service",
|
||||
"homeassistant.helpers.config_validation",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.redact",
|
||||
"homeassistant.helpers.entity",
|
||||
"homeassistant.helpers.entity_platform",
|
||||
"homeassistant.util",
|
||||
"homeassistant.util.dt",
|
||||
"nsh.helpers",
|
||||
"nsh.power",
|
||||
"nsh.jellyfin",
|
||||
"nsh.media_player",
|
||||
"nsh.diagnostics",
|
||||
"nsh.remote",
|
||||
"nsh.image",
|
||||
)
|
||||
#: t14: every runtime service description registered through the stub face below,
|
||||
#: as ``(domain, service, schema)``. A test clears it to see only its own call.
|
||||
SERVICE_SCHEMA_CALLS: list = []
|
||||
|
||||
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")
|
||||
# t64: the apps table is a MULTILINE text field, and the row editor picks an
|
||||
# app out of a select whose options are label/value pairs. Both are real HA
|
||||
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
|
||||
selector_module.SelectOptionDict = dict
|
||||
selector_module.TextSelector = TextSelectorStub
|
||||
selector_module.TextSelectorConfig = _Config
|
||||
|
||||
|
||||
class TextSelectorStub:
|
||||
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
|
||||
|
||||
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
|
||||
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
|
||||
the browser render a textarea, which is the t64 fix, so it is inspectable
|
||||
here the same way the entity selectors' domains are.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("Value is not a string")
|
||||
return data
|
||||
|
||||
|
||||
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 RemoteEntityFeature(enum.IntFlag):
|
||||
"""homeassistant.components.remote.RemoteEntityFeature (the real values)."""
|
||||
|
||||
LEARN_COMMAND = 1
|
||||
DELETE_COMMAND = 2
|
||||
ACTIVITY = 4
|
||||
|
||||
|
||||
class RemoteEntity:
|
||||
"""The RemoteEntity surface HA reads, backed by the ``_attr_*`` convention.
|
||||
|
||||
t15: HA renders the app/activity picker off ``supported_features`` and reads
|
||||
``activity_list``/``current_activity``; both are cached properties on the real
|
||||
base, reading ``_attr_*``. The stub carries that contract (the same shape as the
|
||||
ImageEntity face above) so a test can read what the entity publishes instead of
|
||||
asserting against our own attribute names.
|
||||
"""
|
||||
|
||||
_attr_activity_list: list | None = None
|
||||
_attr_current_activity: str | None = None
|
||||
_attr_supported_features = RemoteEntityFeature(0)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def activity_list(self):
|
||||
return self._attr_activity_list
|
||||
|
||||
@property
|
||||
def current_activity(self):
|
||||
return self._attr_current_activity
|
||||
|
||||
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
|
||||
# t14: the adb_command registration asks for OPTIONAL response support (the
|
||||
# handler returns the command's output). The stub keeps HA's three values
|
||||
# distinct so a test can tell "optional" from "only".
|
||||
core.SupportsResponse = types.SimpleNamespace(
|
||||
NONE="none", OPTIONAL="optional", ONLY="only"
|
||||
)
|
||||
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"
|
||||
# t14: the adb_command target arrives as these two service-call fields (the UI's
|
||||
# target picker merges the selection in as entity_id, an automation targeting a
|
||||
# device sends device_id), so the entity service schema names them.
|
||||
ha_const.ATTR_ENTITY_ID = "entity_id"
|
||||
ha_const.ATTR_DEVICE_ID = "device_id"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
REMOTE="remote",
|
||||
BUTTON="button",
|
||||
IMAGE="image",
|
||||
)
|
||||
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
|
||||
# t15: the feature flag that makes HA offer the activity/app picker, the service
|
||||
# field the picker submits, and the two attributes HA reads off the base class.
|
||||
remote.RemoteEntityFeature = RemoteEntityFeature
|
||||
remote.ATTR_ACTIVITY = "activity"
|
||||
remote.RemoteEntityStateAttribute = types.SimpleNamespace(
|
||||
ACTIVITY_LIST="activity_list",
|
||||
CURRENT_ACTIVITY="current_activity",
|
||||
)
|
||||
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
|
||||
|
||||
# t66: homeassistant.components.image.ImageEntity — the slice the screen
|
||||
# capture entity uses, carrying the REAL contract that matters here: the
|
||||
# attributes HA reads (``content_type``, ``image_last_updated``), the real
|
||||
# ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the
|
||||
# bytes seam, state writing as a no-op (the harness writes no states), and
|
||||
# HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image
|
||||
# entities"). A subclass that forgets its base ``__init__`` fails here too.
|
||||
image_platform = types.ModuleType("homeassistant.components.image")
|
||||
|
||||
class _EntityFace:
|
||||
"""The Entity surface HA reads, backed by the ``_attr_*`` convention.
|
||||
|
||||
The real ImageEntity inherits all of this from Entity; the stub carries
|
||||
the properties so a test can READ what the entity publishes (naming,
|
||||
device, polling, availability) without a state machine.
|
||||
"""
|
||||
|
||||
_attr_available = True
|
||||
_attr_should_poll = True
|
||||
_attr_translation_key = None
|
||||
_attr_unique_id = None
|
||||
_attr_device_info = None
|
||||
_attr_extra_state_attributes = None
|
||||
|
||||
def async_write_ha_state(self) -> None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._attr_available
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
return self._attr_should_poll
|
||||
|
||||
@property
|
||||
def translation_key(self):
|
||||
return self._attr_translation_key
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
return self._attr_device_info
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
return self._attr_extra_state_attributes
|
||||
|
||||
class ImageEntity(_EntityFace):
|
||||
_attr_content_type = "image/jpeg"
|
||||
_attr_image_last_updated = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass, verify_ssl: bool = False) -> None:
|
||||
self.hass = hass
|
||||
|
||||
@property
|
||||
def content_type(self) -> str:
|
||||
return self._attr_content_type
|
||||
|
||||
@property
|
||||
def image_last_updated(self):
|
||||
return self._attr_image_last_updated
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
raise NotImplementedError
|
||||
|
||||
image_platform.ImageEntity = ImageEntity
|
||||
sys.modules["homeassistant.components.image"] = image_platform
|
||||
|
||||
# t66: homeassistant.util.dt — the timestamp helper the image entity stamps
|
||||
# ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA.
|
||||
ha_util = types.ModuleType("homeassistant.util")
|
||||
ha_util.__path__ = []
|
||||
dt_util = types.ModuleType("homeassistant.util.dt")
|
||||
|
||||
def _utcnow():
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
dt_util.utcnow = _utcnow
|
||||
ha_util.dt = dt_util
|
||||
sys.modules["homeassistant.util"] = ha_util
|
||||
sys.modules["homeassistant.util.dt"] = dt_util
|
||||
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
|
||||
# t14: ``async_set_service_schema`` publishes the runtime service DESCRIPTION —
|
||||
# the thing the frontend renders the target picker from (HA's cache is what
|
||||
# get_services serves). The stub records the call instead, so a test can assert
|
||||
# what the integration told HA to offer for nvidia_shield.adb_command.
|
||||
service = types.ModuleType("homeassistant.helpers.service")
|
||||
|
||||
def _stub_set_service_schema(hass, domain, service_name, schema):
|
||||
SERVICE_SCHEMA_CALLS.append((domain, service_name, schema))
|
||||
|
||||
service.async_set_service_schema = _stub_set_service_schema
|
||||
helpers.service = service
|
||||
sys.modules["homeassistant.helpers.service"] = service
|
||||
# t14: the entity service schema names its two target fields through
|
||||
# ``cv.entity_ids`` / ``cv.ensure_list_csv``. The stub keeps HA's SHAPE (None ->
|
||||
# [], text or list -> list, comma text -> split) without the real entity-id
|
||||
# format check — that check belongs to the installed HA and is exercised by
|
||||
# tests/test_adb_command_real_ha.py. The schema is not executed in the stub
|
||||
# suite anyway: those tests call the handler directly.
|
||||
config_validation = types.ModuleType("homeassistant.helpers.config_validation")
|
||||
|
||||
def _ensure_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return list(value)
|
||||
return [value]
|
||||
|
||||
def _ensure_list_csv(value):
|
||||
if isinstance(value, str):
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
return _ensure_list(value)
|
||||
|
||||
config_validation.ensure_list = _ensure_list
|
||||
config_validation.ensure_list_csv = _ensure_list_csv
|
||||
config_validation.entity_ids = _ensure_list_csv
|
||||
helpers.config_validation = config_validation
|
||||
sys.modules["homeassistant.helpers.config_validation"] = config_validation
|
||||
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _RegistryEntry:
|
||||
"""The two attributes the integration reads off a registry entry (t14)."""
|
||||
|
||||
def __init__(self, entity_id):
|
||||
self.entity_id = entity_id
|
||||
self.domain = entity_id.split(".", 1)[0]
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
#: t14: ``device_id -> [entity_id, ...]``, what HA's registry knows about a
|
||||
#: device. Seeded by the test that needs a device target; ``async_get``
|
||||
#: keeps returning a fresh instance, so the binding lives on the class.
|
||||
bindings: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
def _stub_entries_for_device(registry, device_id, include_disabled_entities=False):
|
||||
"""t14: HA's device->entities helper is a MODULE-LEVEL function, not a method
|
||||
on the registry (measured on 2026.10.1) — the stub mirrors that shape, because
|
||||
a stub that put it on the class hid the AttributeError the real API raised.
|
||||
"""
|
||||
del registry, include_disabled_entities
|
||||
return [_RegistryEntry(entry) for entry in _Registry.bindings.get(device_id, [])]
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
entity_registry.async_entries_for_device = _stub_entries_for_device
|
||||
|
||||
|
||||
# t14: how a test gives the stub registry a device -> entities binding (the real
|
||||
# one is filled by HA's own entity registry) and how it takes it back out.
|
||||
def _stub_bind_device(device_id, *entity_ids):
|
||||
_Registry.bindings[device_id] = list(entity_ids)
|
||||
|
||||
entity_registry.bind_device = _stub_bind_device
|
||||
entity_registry.reset_bindings = lambda: _Registry.bindings.clear()
|
||||
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",
|
||||
# t66: the screen-capture platform is a platform like the others, so it
|
||||
# loads here too (and the module-coverage sweep already walks every file)
|
||||
"image",
|
||||
"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",
|
||||
"image",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
DUMP OF SERVICE package:
|
||||
Activity Resolver Table:
|
||||
Schemes:
|
||||
cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "com.google.cast.action.START"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "cast"
|
||||
http: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Category: "android.intent.category.BROWSABLE"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
Scheme: "http"
|
||||
Authority: "www.netflix.com": -1
|
||||
Path: "PatternMatcher{GLOB: /title.*}"
|
||||
Path: "PatternMatcher{GLOB: /watch.*}"
|
||||
Path: "PatternMatcher{LITERAL, /browse}"
|
||||
Path: "PatternMatcher{LITERAL, /home}"
|
||||
Path: "PatternMatcher{GLOB: /deeplink.*}"
|
||||
https: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Category: "android.intent.category.BROWSABLE"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
Scheme: "https"
|
||||
Authority: "www.netflix.com": -1
|
||||
Path: "PatternMatcher{GLOB: /title.*}"
|
||||
Path: "PatternMatcher{GLOB: /watch.*}"
|
||||
Path: "PatternMatcher{LITERAL, /browse}"
|
||||
Path: "PatternMatcher{LITERAL, /home}"
|
||||
Path: "PatternMatcher{GLOB: /deeplink.*}"
|
||||
netflix: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "netflix"
|
||||
nflx: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
|
||||
Action: "android.intent.action.VIEW"
|
||||
Category: "android.intent.category.DEFAULT"
|
||||
Scheme: "nflx"
|
||||
Non-Data Actions:
|
||||
android.intent.action.MAIN:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a2
|
||||
Action: "android.intent.action.MAIN"
|
||||
Category: "android.intent.category.LEANBACK_LAUNCHER"
|
||||
Category: "android.intent.category.LAUNCHER"
|
||||
com.netflix.action.NETFLIX_KEY_START:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a3
|
||||
Action: "com.netflix.action.NETFLIX_KEY_START"
|
||||
com.netflix.action.DIAL_START:
|
||||
deca7fa com.netflix.ninja/.MainActivity filter fc483a4
|
||||
Action: "com.netflix.action.DIAL_START"
|
||||
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
|
||||
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a5
|
||||
Action: "com.netflix.action.DEBUG_PUSH_NOTIFICATION"
|
||||
com.netflix.action.DEBUG_FORCE_EXIT:
|
||||
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a6
|
||||
Action: "com.netflix.action.DEBUG_FORCE_EXIT"
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
androidx.work.DiagnosticsReportReceiver:
|
||||
deca7fa com.netflix.ninja/androidx.work.impl.diagnostics.DiagnosticsReceiver
|
||||
com.google.firebase.MESSAGING_EVENT:
|
||||
deca7fa com.netflix.ninja/com.google.firebase.messaging.FirebaseMessagingService
|
||||
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
|
||||
deca7fa com.netflix.ninja/.push.DebugPushReceiver
|
||||
Service Resolver Table:
|
||||
Non-Data Actions:
|
||||
com.google.firebase.INSTANCE_ID_EVENT:
|
||||
deca7fa com.netflix.ninja/com.google.firebase.iid.FirebaseInstanceIdService
|
||||
Provider Resolver Table:
|
||||
Authorities:
|
||||
com.netflix.ninja.androidx-startup:
|
||||
deca7fa com.netflix.ninja/androidx.startup.InitializationProvider
|
||||
Permissions:
|
||||
Permission [com.netflix.ninja.C2D_MESSAGE]:
|
||||
sourcePackage=com.netflix.ninja
|
||||
Packages:
|
||||
Package [com.netflix.ninja] (7f1a2b3c):
|
||||
userId=10123
|
||||
pkg=Package{7f1a2b3c com.netflix.ninja}
|
||||
nonLocalizedLabel=null
|
||||
labelRes=0x7f0f0d31
|
||||
versionCode=61102 minSdk=21 targetSdk=29
|
||||
versionName=6.11.2 build 3413
|
||||
codePath=/data/app/com.netflix.ninja-1/base.apk
|
||||
ConstraintProxy.BatteryBroadcastReceiver=
|
||||
com.netflix.ninja/androidx.work.impl.constraints.trackers.BatteryChargingProxy
|
||||
Activity Resolver Table: (nothing else listed for this package)
|
||||
DUMP OF SERVICE activity:
|
||||
ACTIVITY MANAGER PENDING INTENTS (dumpsys activity pending)
|
||||
(nothing)
|
||||
DUMP OF SERVICE meminfo:
|
||||
Applications Memory Usage (in Kilobytes):
|
||||
DUMP OF SERVICE procstats:
|
||||
Process Stats:
|
||||
DUMP OF SERVICE usagestats:
|
||||
In-memory rollup:
|
||||
DUMP OF SERVICE batterystats:
|
||||
Battery Stats:
|
||||
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() == []
|
||||
@@ -0,0 +1,372 @@
|
||||
"""t14: `nvidia_shield.adb_command` against a REAL Home Assistant install.
|
||||
|
||||
The user's report was that the Shield could not be SELECTED for the action, so he
|
||||
ran his diagnostics through the deprecated ``androidtv`` integration. A stub suite
|
||||
cannot settle that: the picker is whatever HA hands the browser, and "was the
|
||||
command sent to the Shield I picked?" is decided by HA's own service registry,
|
||||
schema validation and target merging.
|
||||
|
||||
What this file drives, on the installed HA (2026.10.1):
|
||||
|
||||
* the REAL ``hass.services`` registry — the registration our ``async_setup`` makes;
|
||||
* the REAL websocket command the frontend calls (``get_services``), asserting the
|
||||
payload carries ``target: {entity: [{domain: media_player}]}`` — that payload IS
|
||||
the picker;
|
||||
* the REAL ``hass.services.async_call`` with ``return_response=True`` — asserting
|
||||
the output comes back under ``adb_response`` (the key the user's captures use)
|
||||
and that a call without it behaves as before;
|
||||
* the REAL entity registry, for the ``device_id`` shape an automation sends.
|
||||
|
||||
WHAT THIS FILE CANNOT PROVE: there is no browser here, so nothing about how the
|
||||
picker LOOKS. It proves the wire contract — the registration, the metadata the UI
|
||||
consumes, and the resolved call. Every test skips cleanly when homeassistant is not
|
||||
installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real service registry is the point of this file",
|
||||
)
|
||||
|
||||
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.exceptions import ServiceValidationError # 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,
|
||||
)
|
||||
from homeassistant.helpers.service import ( # noqa: E402
|
||||
async_get_all_descriptions,
|
||||
)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
SERVICE = "adb_command"
|
||||
ENTITY_ID = "media_player.shield"
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""The ADB surface the handler uses: one shell call, real values back."""
|
||||
|
||||
def __init__(self, output="ok", error=None):
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, command):
|
||||
self.calls.append(command)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.output
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with the real registries and the real service registry.
|
||||
|
||||
No core bootstrap: the registries are filled directly with the two containers
|
||||
core's loader fills (same objects), exactly as the t56 flow probe does — so an
|
||||
EntitySelector / target expansion sees the entities the real one would.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
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
|
||||
# the real entry manager: device_registry.async_get_or_create needs an entry it
|
||||
# can look up, exactly as a real device registration does
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain=DOMAIN,
|
||||
title="Shield",
|
||||
data={},
|
||||
options={},
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
object.__setattr__(entry, "_options", {})
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
hass._shield_entry = entry
|
||||
return hass
|
||||
|
||||
|
||||
@pytest.fixture(name="integration")
|
||||
def integration_fixture():
|
||||
"""The integration package, imported by path under its real name."""
|
||||
return importlib.import_module("custom_components.nvidia_shield")
|
||||
|
||||
|
||||
@pytest.fixture(name="media_player_module")
|
||||
def media_player_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.media_player")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="shield")
|
||||
async def shield_fixture(ha, integration, media_player_module):
|
||||
"""Set the integration up, and register ONE Shield as an adb_command target.
|
||||
|
||||
Yields the fake manager behind that entity, so a test can see exactly how many
|
||||
commands reached the device.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
manager = SimpleNamespace(adb_poller=poller, connected=True)
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = manager
|
||||
try:
|
||||
yield poller
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
|
||||
def _service_payload(descriptions):
|
||||
return descriptions[DOMAIN][SERVICE]
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
"""The websocket connection shape ``handle_get_services`` needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: list[dict] = []
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def send_result(self, msg_id, payload):
|
||||
self.messages.append({"id": msg_id, "type": "result", "result": payload})
|
||||
|
||||
|
||||
async def test_the_registration_declares_a_response(ha, integration, shield):
|
||||
"""t14: a service that answers must say so — and OPTIONAL keeps callers free."""
|
||||
from homeassistant.core import SupportsResponse
|
||||
|
||||
service = ha.services.async_services()[DOMAIN][SERVICE]
|
||||
assert service.supports_response is SupportsResponse.OPTIONAL
|
||||
|
||||
|
||||
async def test_the_frontend_payload_carries_the_target_picker(ha, integration, shield):
|
||||
"""THE fix: what the browser receives for this action must offer the Shield.
|
||||
|
||||
Driven through the real websocket command (``get_services``), because that is
|
||||
the payload the UI builds its action form and target picker from.
|
||||
"""
|
||||
payload = await async_get_all_descriptions(ha)
|
||||
description = _service_payload(payload)
|
||||
|
||||
# the entity target the picker is rendered from
|
||||
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
# a response-bearing action: the UI can offer to return the output
|
||||
assert description["response"] == {"optional": True}
|
||||
# both fields survive the runtime description (the form would be empty without)
|
||||
assert set(description["fields"]) == {"command", "entity_id"}
|
||||
|
||||
# ...and it serialises as JSON, which is what actually crosses the wire
|
||||
assert json.loads(json.dumps(payload))[DOMAIN][SERVICE]["target"] == {
|
||||
"entity": [{"domain": ["media_player"]}]
|
||||
}
|
||||
|
||||
|
||||
async def test_the_websocket_command_serves_the_same_payload(ha, integration, shield):
|
||||
"""The exact command the frontend calls, not just the accessor behind it."""
|
||||
connection = RecordingConnection()
|
||||
# the command is decorated: it schedules the handler as a background task and
|
||||
# returns None, which is exactly how the frontend's request is served
|
||||
ws_commands.handle_get_services(ha, connection, {"id": 7, "type": "get_services"})
|
||||
await ha.async_block_till_done()
|
||||
|
||||
# HA serialises the response to JSON BYTES before it reaches the socket, so this
|
||||
# is the exact text the browser parses for the action form
|
||||
assert len(connection.messages) == 1
|
||||
message = json.loads(connection.messages[0])
|
||||
assert message["id"] == 7
|
||||
assert message["success"] is True
|
||||
served = message["result"][DOMAIN][SERVICE]
|
||||
assert served["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
assert served["response"] == {"optional": True}
|
||||
assert set(served["fields"]) == {"command", "entity_id"}
|
||||
|
||||
|
||||
async def test_a_call_returns_the_command_output(ha, integration, shield):
|
||||
"""The response the user's captures read: ``adb_response``, the old key."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo hi", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo hi"]
|
||||
|
||||
|
||||
async def test_a_call_from_the_ui_target_shape_returns_the_output(ha, integration, shield):
|
||||
"""HA merges a picked target into the call as a LIST — accept it."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo list", "entity_id": [ENTITY_ID]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo list"]
|
||||
|
||||
|
||||
async def test_a_call_without_the_response_still_works(ha, integration, shield):
|
||||
"""Existing YAML callers: same data, no ``return_response`` — unchanged."""
|
||||
result = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo old", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert shield.calls == ["echo old"]
|
||||
|
||||
|
||||
async def test_the_device_target_shape_resolves_through_the_registry(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""An automation that targets the DEVICE sends ``device_id``: resolve it."""
|
||||
device = device_registry.async_get(ha).async_get_or_create(
|
||||
config_entry_id=ha._shield_entry.entry_id,
|
||||
identifiers={(DOMAIN, "shield-device")},
|
||||
name="Shield",
|
||||
)
|
||||
er.async_get(ha).async_get_or_create(
|
||||
"media_player",
|
||||
DOMAIN,
|
||||
"uid_shield",
|
||||
device_id=device.id,
|
||||
suggested_object_id="shield",
|
||||
)
|
||||
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo device", "device_id": device.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"adb_response": "ok"}
|
||||
assert shield.calls == ["echo device"]
|
||||
|
||||
|
||||
async def test_no_target_is_an_error_naming_what_to_pass(ha, integration, shield):
|
||||
"""Never a silent no-op: HA's own validation is not the only guard."""
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN, SERVICE, {"command": "echo x"}, blocking=True
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "no_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_two_targets_send_nothing(ha, integration, shield, media_player_module):
|
||||
"""A raw shell command is never broadcast to Shields the user did not pick."""
|
||||
other = SimpleNamespace(adb_poller=FakePoller(), connected=True)
|
||||
media_player_module._ADB_COMMAND_TARGETS["media_player.other"] = other
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": [ENTITY_ID, "media_player.other"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "ambiguous_target"
|
||||
assert shield.calls == []
|
||||
assert other.adb_poller.calls == []
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop("media_player.other", None)
|
||||
|
||||
|
||||
async def test_an_unknown_entity_is_an_error(ha, integration, shield):
|
||||
"""Another integration's media player is not a Shield to run shell on."""
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": "media_player.someone_else"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "unsupported_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_an_unreachable_shield_is_an_error_not_an_empty_response(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""The acceptance's honesty clause: ADB down → error, not ``None`` back."""
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=None, connected=False
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert err.value.translation_key == "adb_unavailable"
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
|
||||
async def test_a_failing_transport_surfaces_the_error(
|
||||
ha, integration, shield, media_player_module
|
||||
):
|
||||
"""A transport failure mid-call propagates — never ``adb_response: None``."""
|
||||
poller = FakePoller(error=ConnectionError("adb socket dead"))
|
||||
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=poller, connected=True
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ConnectionError):
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"command": "echo x", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
finally:
|
||||
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""t14: the adb_command ACTION is selectable — the contract HA reads.
|
||||
|
||||
The user's report was that the Shield could not be selected for the
|
||||
``nvidia_shield.adb_command`` action, so he ran his diagnostics through the
|
||||
deprecated ``androidtv`` integration instead. The cause was measured against the
|
||||
installed Home Assistant (2026.10.1), not guessed:
|
||||
|
||||
* ``ServiceRegistry.async_register`` takes no ``target`` — a target CANNOT be
|
||||
declared at registration;
|
||||
* the frontend's target picker comes from the service DESCRIPTION, which HA builds
|
||||
from ``services.yaml`` or from ``homeassistant.helpers.service.async_set_service_schema``,
|
||||
and the runtime description WINS;
|
||||
* therefore THIS integration must publish a description carrying
|
||||
``target: {entity: [{domain: media_player}]}`` — that is the picker.
|
||||
|
||||
This file pins the published contract itself, without any HA installed: the
|
||||
description, and the copy in BOTH translation files. The registration and the
|
||||
resolution are exercised in ``test_command_routing.py::TestAdbCommandService``
|
||||
(stubs) and in ``tests/test_adb_command_real_ha.py`` (real HA).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SHIELD = _ROOT / "custom_components" / "nvidia_shield"
|
||||
|
||||
|
||||
@pytest.fixture(name="const")
|
||||
def const_fixture():
|
||||
"""The integration's const module, loaded by path like every other test."""
|
||||
import sys
|
||||
|
||||
import conftest
|
||||
|
||||
if "nsh.const" not in sys.modules:
|
||||
conftest._load_module("nsh.const", conftest._SHIELD_DIR / "const.py")
|
||||
return sys.modules["nsh.const"]
|
||||
|
||||
|
||||
def _strings(name: str) -> dict:
|
||||
return json.loads((_SHIELD / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestThePublishedTarget:
|
||||
"""What HA is told the action accepts."""
|
||||
|
||||
def test_the_target_is_the_shields_media_player_entity(self, const):
|
||||
assert const.ADB_COMMAND_SERVICE_TARGET == {
|
||||
"entity": [{"domain": const.ADB_COMMAND_TARGET_DOMAIN}]
|
||||
}
|
||||
assert const.ADB_COMMAND_TARGET_DOMAIN == "media_player"
|
||||
|
||||
def test_the_description_carries_the_target_and_the_two_fields(self, const):
|
||||
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
|
||||
# the picker: without this key the UI offers no Shield at all
|
||||
assert description["target"] is const.ADB_COMMAND_SERVICE_TARGET
|
||||
assert set(description["fields"]) == {"command", "entity_id"}
|
||||
assert description["fields"]["command"]["required"] is True
|
||||
# a plain (non-required) field: the TARGET is what selects the Shield, and
|
||||
# an entity_id given anyway still has to point at a participant
|
||||
assert "required" not in description["fields"]["entity_id"]
|
||||
|
||||
def test_the_response_key_is_the_one_the_old_integration_returned(self, const):
|
||||
# the user's captures read `adb_response`; keeping the name means a template
|
||||
# written against `androidtv.adb_command` keeps working
|
||||
assert const.ADB_COMMAND_RESPONSE_KEY == "adb_response"
|
||||
|
||||
|
||||
class TestTheCopyMatchesBothTranslationFiles:
|
||||
"""The description's prose is duplicated in the translations — so it cannot drift.
|
||||
|
||||
``strings.json``/``translations/en.json`` are where HA's translations live and
|
||||
the description is what the frontend receives; the two copies must stay
|
||||
identical, and every raised translation_key needs copy in BOTH files or the user
|
||||
sees a bare key (t29's lesson, applied to t14's two new keys).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
|
||||
def test_the_service_copy_matches_the_description(self, const, name):
|
||||
service = _strings(name)["services"]["adb_command"]
|
||||
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
|
||||
|
||||
assert service["name"] == description["name"]
|
||||
assert service["description"] == description["description"]
|
||||
for field, copy in description["fields"].items():
|
||||
assert service["fields"][field]["name"] == copy["name"]
|
||||
assert service["fields"][field]["description"] == copy["description"]
|
||||
|
||||
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
|
||||
def test_the_new_exception_keys_have_copy(self, const, name):
|
||||
exceptions = _strings(name)["exceptions"]
|
||||
|
||||
for key in ("no_target", "ambiguous_target", "unsupported_target"):
|
||||
assert exceptions[key]["title"], (name, key)
|
||||
assert exceptions[key]["message"], (name, key)
|
||||
|
||||
# the placeholders the handler fills in are the ones the copy names
|
||||
assert "{entity_id}" in exceptions["ambiguous_target"]["message"]
|
||||
assert "{entity_id}" in exceptions["unsupported_target"]["message"]
|
||||
assert "{service}" in exceptions["no_target"]["message"]
|
||||
|
||||
def test_both_files_are_identical(self):
|
||||
# they are two copies of the same English copy — a divergence would mean one
|
||||
# of them is dead weight
|
||||
assert (_SHIELD / "strings.json").read_text(
|
||||
encoding="utf-8"
|
||||
) == (_SHIELD / "translations" / "en.json").read_text(encoding="utf-8")
|
||||
|
||||
def test_the_entity_field_copy_names_the_picker(self):
|
||||
# the field the YAML caller passes has to say what to pass, now that the
|
||||
# picker is the primary way in
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
copy = _strings(name)["services"]["adb_command"]["fields"]["entity_id"]
|
||||
assert "media_player" in copy["description"]
|
||||
assert "device_id" in copy["description"]
|
||||
@@ -0,0 +1,646 @@
|
||||
"""t23/O1 — with ADB switched off, NO path reaches the device.
|
||||
|
||||
The t22 review measured the harm: with a poller injected into an entry whose own
|
||||
option said ``adb_enabled: False``, the installed-apps sweep sent **4** real
|
||||
shells and a label probe sent **1** more. Both gated on the POLLER's absence,
|
||||
which is a property of the setup code (``_build_poller_factory`` returns None when
|
||||
ADB is off) — exactly the indirect guarantee the t21 repair had already proven
|
||||
insufficient for the intent scan.
|
||||
|
||||
This file drives EVERY path in the connection manager that can touch the device
|
||||
and asserts on the TRAFFIC (shells, polls, commands, connects), because the
|
||||
traffic is the harm — a correct-looking status string is not the point. The
|
||||
device side is a scripted double that would answer everything: no real Shield is
|
||||
involved, so what is proven is that nothing was sent, not what firmware would do.
|
||||
|
||||
WHAT "NOTHING IS CACHED" MEANS EXACTLY (t24/O4): the ONE write a disabled path can
|
||||
still make is a retry DEBT — ``_app_label_failed.add(package)`` in ``_dump_label``
|
||||
— and a debt is not an answer: the label cache (``_app_labels``) stays untouched,
|
||||
``app_label`` keeps returning None, and the debt is settled the moment the probe
|
||||
finally runs with ADB back on. Asserted in
|
||||
``test_the_only_write_while_off_is_a_retry_debt_not_an_answer``.
|
||||
|
||||
WHAT THIS FILE DOES NOT GATE (t25/O1): the paths where "ADB off must mean no
|
||||
traffic" is deliberately NOT enforced, each listed in :data:`CARVE_OUTS` with its
|
||||
mitigation and with the test that asserts that mitigation — the Pair ADB button's
|
||||
press and ``ShieldConnectionManager.test_connect``. Naming them is the point: an
|
||||
audit that silently excludes a path is not an audit, and a carve-out whose
|
||||
assertion does not exist is prose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from nsh.connection import (
|
||||
FIELD_AWAKE,
|
||||
FIELD_FOREGROUND,
|
||||
FIELD_RUNNING_APPS,
|
||||
FIELD_SESSIONS,
|
||||
ShieldConnectionManager,
|
||||
)
|
||||
from nsh.const import CONF_ADB_ENABLED, CONF_LOGCAT_EVENTS
|
||||
from nsh.ha_adb_common import (
|
||||
INTENT_SCAN_ADB_DISABLED,
|
||||
INTENT_SCAN_ADB_UNAVAILABLE,
|
||||
INTENT_SCAN_FRESH,
|
||||
INTENT_SCAN_FRESH_EMPTY,
|
||||
)
|
||||
|
||||
PKG = "com.netflix.ninja"
|
||||
|
||||
#: A minimal answer that IS a resolver section, so a scan that were allowed to run
|
||||
#: would count as a real answer (nothing here fails for a second reason).
|
||||
RESOLVER_SECTION = (
|
||||
"Activity Resolver Table:\n"
|
||||
" Schemes:\n"
|
||||
" https:\n"
|
||||
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
|
||||
' Action: "android.intent.action.VIEW"\n'
|
||||
"Service Resolver Table:\n"
|
||||
)
|
||||
|
||||
|
||||
class WillingPoller:
|
||||
"""A poller that answers EVERY call and records every one (t23/O1).
|
||||
|
||||
Nothing here refuses: if a path is still gated by the wrong fact, this double
|
||||
will happily talk to "the device" and the shell/command count proves it.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.connected = True
|
||||
self.awake = True
|
||||
self.foreground = "app.a"
|
||||
self.audio_dump = " package=app.a\n"
|
||||
self.shell_calls: list[str] = []
|
||||
self.poll_calls: list[str] = []
|
||||
self.media_calls: list[str] = []
|
||||
self.digit_calls: list[str] = []
|
||||
self.foreground_sends: list[str] = []
|
||||
self.enter_calls = 0
|
||||
self.connect_calls = 0
|
||||
self.disconnect_calls = 0
|
||||
self.sessions = [SimpleNamespace(package="app.a", playing=True)]
|
||||
|
||||
# ── the transport ──
|
||||
async def connect(self) -> bool:
|
||||
self.connect_calls += 1
|
||||
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
|
||||
|
||||
# ── the heartbeat ──
|
||||
async def is_awake(self) -> bool | None:
|
||||
self.poll_calls.append("is_awake")
|
||||
return self.awake
|
||||
|
||||
async def poll_media_sessions(self, foreground_package=None, head_lines=200):
|
||||
self.poll_calls.append("poll_media_sessions")
|
||||
return self.sessions[0] if self.sessions else None
|
||||
|
||||
@property
|
||||
def all_sessions(self):
|
||||
return self.sessions
|
||||
|
||||
@property
|
||||
def last_dump(self):
|
||||
return self.audio_dump
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
self.poll_calls.append("poll_running_apps")
|
||||
return [{"package": "app.a", "name": "App A", "icon": None}]
|
||||
|
||||
async def poll_foreground(self) -> str | None:
|
||||
self.poll_calls.append("poll_foreground")
|
||||
return self.foreground
|
||||
|
||||
# ── anything a shell can reach ──
|
||||
async def shell(self, command: str):
|
||||
self.shell_calls.append(command)
|
||||
if "Activity Resolver Table" in command:
|
||||
return RESOLVER_SECTION
|
||||
if "pm list packages" in command:
|
||||
return f"package:{PKG}\n"
|
||||
if "resolve-activity" in command:
|
||||
return f"{PKG}/.MainActivity\n"
|
||||
if "pm dump" in command:
|
||||
return 'Application Label: "Netflix"\n'
|
||||
return "ok\n"
|
||||
|
||||
async def send_media_command(self, command: str) -> bool:
|
||||
self.media_calls.append(command)
|
||||
return True
|
||||
|
||||
async def bring_to_foreground(self, package: str) -> bool:
|
||||
self.foreground_sends.append(package)
|
||||
return True
|
||||
|
||||
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||
self.digit_calls.append(digit)
|
||||
return True
|
||||
|
||||
async def send_enter_keyevent(self) -> bool:
|
||||
self.enter_calls += 1
|
||||
return True
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {"host": "shield.invalid", "port": 5555, "key_path": "/tmp/does-not-exist"}
|
||||
|
||||
@property
|
||||
def traffic(self) -> list[str]:
|
||||
"""EVERY device-touching call, in one list — the harm in one place (t23)."""
|
||||
return [
|
||||
*(f"shell:{call}" for call in self.shell_calls),
|
||||
*(f"poll:{call}" for call in self.poll_calls),
|
||||
*(f"media:{call}" for call in self.media_calls),
|
||||
*(f"digit:{digit}" for digit in self.digit_calls),
|
||||
*(f"foreground:{pkg}" for pkg in self.foreground_sends),
|
||||
*(["enter"] * self.enter_calls),
|
||||
*(["connect"] * self.connect_calls),
|
||||
]
|
||||
|
||||
|
||||
def _entry(*, adb_enabled: bool, **options):
|
||||
"""The entry's OWN options decide — this is the fact every path must ask."""
|
||||
return SimpleNamespace(
|
||||
options={
|
||||
CONF_ADB_ENABLED: adb_enabled,
|
||||
# ON, so the logcat path is skipped for the OPTION's reason (ADB off)
|
||||
# rather than for its own (a stream nobody asked for).
|
||||
CONF_LOGCAT_EVENTS: True,
|
||||
**options,
|
||||
},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
|
||||
def _manager(poller, *, adb_enabled: bool) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
|
||||
)
|
||||
|
||||
|
||||
#: THE AUDIT (t23/O1), as data: every path in the manager that can reach the
|
||||
#: device, with the call that drives it. Each is run against a manager whose
|
||||
#: option says ADB is off, and the poller's traffic must stay EMPTY.
|
||||
AUDITED_PATHS = [
|
||||
("intent scan", lambda m: m.async_scan_intents(PKG)),
|
||||
("intent scan (forced)", lambda m: m.async_scan_intents(PKG, force=True)),
|
||||
("installed-apps refresh", lambda m: m.async_refresh_installed_apps(force=True)),
|
||||
("installed-apps refresh (non-forced)", lambda m: m.async_refresh_installed_apps()),
|
||||
("app-label lookup", lambda m: _sync_label(m)),
|
||||
("app-label drain (watchdog side)", lambda m: _drain(m)),
|
||||
("adb text entry", lambda m: m.send_adb_input_text("hello")),
|
||||
("media key send", lambda m: m.send_media_command("play")),
|
||||
("bring to foreground", lambda m: m.bring_to_foreground(PKG)),
|
||||
("digit key send", lambda m: m.send_digit_key("5")),
|
||||
("enter key send", lambda m: m.send_enter_key()),
|
||||
("watchdog cycle", lambda m: m._watchdog_cycle(5.0)),
|
||||
("heartbeat refresh", lambda m: m._refresh()),
|
||||
("logcat ensure", lambda m: _logcat(m)),
|
||||
("logcat open", lambda m: _open_logcat(m)),
|
||||
("logcat ladder", lambda m: m._run_logcat_stream()),
|
||||
("watchdog start", lambda m: _start_watchdog(m)),
|
||||
]
|
||||
|
||||
|
||||
#: NAMED CARVE-OUTS (t25/O1) — the device paths this audit does NOT gate, each with
|
||||
#: what it does, why that is safe, and the assertion that proves the mitigation. A
|
||||
#: carve-out is a claim like every other claim here, so
|
||||
#: ``test_every_carve_out_names_a_real_assertion`` checks that the named assertion
|
||||
#: actually exists in the tree: a carve-out cannot point at a phantom.
|
||||
CARVE_OUTS = (
|
||||
{
|
||||
"name": "Pair ADB button press (button.py::NvidiaShieldPairAdbButton)",
|
||||
"what_it_does": (
|
||||
"builds its OWN AdbMediaSessionPoller and connects it, and never reads "
|
||||
"the entry's ADB option — MEASURED ctor/connect/disconnect 1/1/1 with "
|
||||
"the option OFF, identical to option-ON (driven, not quoted)"
|
||||
),
|
||||
"why_safe": (
|
||||
"there is no production route to the press with ADB off: "
|
||||
"button.async_setup_entry adds that entity ONLY when the option is on, "
|
||||
"so a disabled entry has no button to press; every options save runs "
|
||||
"through NvidiaShieldOptionsFlow(OptionsFlowWithReload), which reloads "
|
||||
"the entry and re-runs that setup, so turning ADB off takes the button "
|
||||
"away; and a press is user-initiated — the same class as the "
|
||||
"test_connect pairing probe below"
|
||||
),
|
||||
"asserted_by": (
|
||||
"tests/test_intents_registration.py",
|
||||
"test_no_pair_adb_button_while_adb_is_off",
|
||||
),
|
||||
"measured_by": (
|
||||
"tests/test_intents_registration.py",
|
||||
"test_the_press_does_not_consult_the_option_and_that_is_pinned",
|
||||
),
|
||||
"not_the_same_as": (
|
||||
"the INTENT button, which CAN exist for an ADB-off entry and is NOT a "
|
||||
"carve-out: it sends through run_registered_intent, which resolves the "
|
||||
"poller through the gated adb_poller accessor (media_player.py:2317) and "
|
||||
"refuses with `intents_need_adb` — zero traffic"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ShieldConnectionManager.test_connect (the config-flow pairing probe)",
|
||||
"what_it_does": (
|
||||
"opens a throwaway AdbMediaSessionPoller and connects, before any entry "
|
||||
"exists, consulting no option at all"
|
||||
),
|
||||
"why_safe": (
|
||||
"the setup menu offers that step only while the setup's own ADB option "
|
||||
"is on (the menu row is built from _user_data[CONF_ADB_ENABLED]), and "
|
||||
"the probe IS the user's explicit pairing action"
|
||||
),
|
||||
"asserted_by": (
|
||||
"tests/test_adb_off_gates_every_device_path.py",
|
||||
"test_the_pairing_probe_is_offered_only_while_the_option_is_on",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _maybe_await(value):
|
||||
"""Drive an audited path: some are coroutines, some are plain calls (t23)."""
|
||||
if asyncio.iscoroutine(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
|
||||
def _sync_label(manager):
|
||||
return manager.app_label(PKG)
|
||||
|
||||
|
||||
async def _settle_label_tasks(manager):
|
||||
"""Await any label fetch the drain started, so its shell WOULD have happened."""
|
||||
for task in list(manager._tasks):
|
||||
if task.get_name().startswith("app_label"):
|
||||
with contextlib.suppress(Exception):
|
||||
await task
|
||||
|
||||
|
||||
async def _drain(manager):
|
||||
"""The render path queues a label fetch, then the watchdog drains the queue."""
|
||||
manager.app_label(PKG)
|
||||
manager._drain_app_label_requests()
|
||||
await _settle_label_tasks(manager)
|
||||
|
||||
|
||||
def _logcat(manager):
|
||||
manager._ensure_logcat_stream()
|
||||
|
||||
|
||||
def _open_logcat(manager):
|
||||
"""The logcat OPEN path: it raises while disabled, so swallow it here.
|
||||
|
||||
``test_the_logcat_open_refuses_with_the_disabled_message`` asserts the message;
|
||||
this driver only has to prove the raise cost no traffic.
|
||||
"""
|
||||
with contextlib.suppress(RuntimeError):
|
||||
manager._open_logcat_stream()
|
||||
|
||||
|
||||
async def _start_watchdog(manager):
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0.01) # would be one cycle if the loop had started
|
||||
await manager.async_stop()
|
||||
|
||||
|
||||
class TestEveryAuditedPathSendsNothing:
|
||||
"""ADB off + a poller that WOULD answer = zero traffic on every path."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "drive"), AUDITED_PATHS, ids=[name for name, _ in AUDITED_PATHS]
|
||||
)
|
||||
async def test_the_path_does_not_touch_the_device(self, name, drive):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await _maybe_await(drive(manager))
|
||||
await asyncio.sleep(0) # let any task the path started really run
|
||||
|
||||
assert poller.traffic == [], f"{name} reached the device: {poller.traffic}"
|
||||
|
||||
async def test_a_full_cycle_of_every_path_still_sends_nothing(self):
|
||||
"""One manager, every path in sequence — the whole cycle, not one call."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
# 1. the intent scan (t21's repair, asked again one layer deeper)
|
||||
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
|
||||
assert (
|
||||
await manager.async_scan_intents(PKG, force=True)
|
||||
).status == INTENT_SCAN_ADB_DISABLED
|
||||
|
||||
# 2. the installed-apps sweep — the 4-shell path the review measured
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps() == []
|
||||
|
||||
# 3. the app-label probe — the 1-shell path, both entry points
|
||||
assert manager.app_label(PKG) is None
|
||||
manager._drain_app_label_requests()
|
||||
await _settle_label_tasks(manager)
|
||||
|
||||
# 4. text entry and every key/command delegation
|
||||
assert await manager.send_adb_input_text("hello") is False
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert await manager.bring_to_foreground(PKG) is False
|
||||
assert await manager.send_digit_key("5") is False
|
||||
assert await manager.send_enter_key() is False
|
||||
|
||||
# 5. the heartbeat, the watchdog and the logcat stream
|
||||
await manager._watchdog_cycle(5.0)
|
||||
await _start_watchdog(manager)
|
||||
manager._ensure_logcat_stream()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# 6. the public accessor the OUT-OF-FILE consumers (adb_command service,
|
||||
# screen capture, intent shell) read — it must refuse too
|
||||
assert manager.adb_poller is None
|
||||
|
||||
assert poller.traffic == [], poller.traffic
|
||||
|
||||
async def test_nothing_is_cached_when_the_cycle_is_refused(self):
|
||||
"""No cache write either: an unpermitted scan is not an answer."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager.async_scan_intents(PKG)
|
||||
await manager.async_refresh_installed_apps(force=True)
|
||||
manager.app_label(PKG)
|
||||
manager._drain_app_label_requests()
|
||||
await manager._refresh()
|
||||
|
||||
assert manager.intent_scan_state(PKG) is None, "no intent answer"
|
||||
assert manager.installed_apps() == [], "no app table"
|
||||
assert manager.installed_apps_stale is True, "nothing was stamped fresh"
|
||||
assert manager.app_label_requests == set(), "nothing queued"
|
||||
assert manager.app_label_inflight == set(), "nothing in flight"
|
||||
assert manager.store.get(FIELD_AWAKE) is None, "no wake reading"
|
||||
assert manager.store.get(FIELD_SESSIONS, []) == []
|
||||
assert manager.store.get(FIELD_RUNNING_APPS, []) == []
|
||||
assert manager.store.get(FIELD_FOREGROUND) is None
|
||||
assert manager.awake is None
|
||||
|
||||
|
||||
class TestTheWatchdogNeverConnects:
|
||||
"""A TCP connect is device traffic too — the loop must not even start."""
|
||||
|
||||
async def test_the_watchdog_does_not_build_a_poller_or_connect(self):
|
||||
ours = WillingPoller()
|
||||
built = []
|
||||
|
||||
def factory():
|
||||
built.append(1)
|
||||
return ours
|
||||
|
||||
manager = ShieldConnectionManager(
|
||||
hass=None,
|
||||
entry=_entry(adb_enabled=False),
|
||||
poller_factory=factory,
|
||||
)
|
||||
await manager.async_start()
|
||||
await asyncio.sleep(0.02)
|
||||
await manager.async_stop()
|
||||
|
||||
assert built == [], "the factory was never asked for a poller"
|
||||
assert ours.connect_calls == 0, "and nothing was connected"
|
||||
assert ours.traffic == []
|
||||
|
||||
async def test_a_direct_cycle_does_not_connect_either(self):
|
||||
poller = WillingPoller()
|
||||
poller.connected = False
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager._watchdog_cycle(5.0)
|
||||
|
||||
assert poller.connect_calls == 0
|
||||
assert poller.traffic == []
|
||||
|
||||
|
||||
class TestTheOptionIsACheckNotALatch:
|
||||
"""Turning ADB back on resumes normal work — no remembered decision."""
|
||||
|
||||
async def test_the_same_manager_resumes_when_the_option_flips(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.app_label(PKG) is None
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert poller.traffic == [], "nothing while it is off"
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True # the user turns ADB on
|
||||
|
||||
rows = await manager.async_refresh_installed_apps(force=True)
|
||||
assert rows, "the sweep really ran once ADB is on"
|
||||
assert manager.app_label("com.other.app") is None # queued, not sent yet
|
||||
assert manager.app_label_requests == {"com.other.app"}
|
||||
assert await manager.send_media_command("play") is True
|
||||
assert await manager.send_digit_key("5") is True
|
||||
assert manager.adb_poller is poller
|
||||
assert poller.shell_calls, "the device was reached again"
|
||||
assert poller.media_calls == ["play"]
|
||||
assert poller.digit_calls == ["5"]
|
||||
|
||||
async def test_the_intent_scan_resumes_too(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True
|
||||
|
||||
result = await manager.async_scan_intents(PKG)
|
||||
assert result.status in (INTENT_SCAN_FRESH, INTENT_SCAN_FRESH_EMPTY)
|
||||
assert result.answered is True
|
||||
assert manager.intent_scan_state(PKG) is not None, "the answer is cached now"
|
||||
|
||||
|
||||
class TestTheOtherStatusesAreUntouched:
|
||||
"""ADB ON with no poller is 'unavailable', never 'disabled' (t21's split)."""
|
||||
|
||||
async def test_no_poller_reads_as_unavailable(self):
|
||||
manager = _manager(None, adb_enabled=True)
|
||||
|
||||
assert (await manager.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_UNAVAILABLE
|
||||
)
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps_status == "unknown"
|
||||
assert await manager.send_media_command("play") is False
|
||||
|
||||
async def test_adb_off_and_no_poller_are_told_apart(self):
|
||||
disabled = _manager(WillingPoller(), adb_enabled=False)
|
||||
unbuilt = _manager(None, adb_enabled=True)
|
||||
|
||||
assert (await disabled.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_DISABLED
|
||||
)
|
||||
assert (await unbuilt.async_scan_intents(PKG)).status == (
|
||||
INTENT_SCAN_ADB_UNAVAILABLE
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _source(relative_path: str) -> str:
|
||||
return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestTheNamedCarveOuts:
|
||||
"""Every exclusion is a NAMED claim with an assertion behind it (t25/O1)."""
|
||||
|
||||
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
|
||||
def test_every_carve_out_names_a_real_assertion(self, carve_out):
|
||||
"""No phantom carve-outs: the named assertion must exist in the tree."""
|
||||
for key in ("asserted_by", "measured_by"):
|
||||
pointer = carve_out.get(key)
|
||||
if pointer is None:
|
||||
continue
|
||||
relative, name = pointer
|
||||
assert f"def {name}(" in _source(relative), (
|
||||
f"{carve_out['name']} claims {key}={relative}::{name}, which does "
|
||||
"not exist"
|
||||
)
|
||||
assert carve_out["what_it_does"], "a carve-out states what it does"
|
||||
assert carve_out["why_safe"], "a carve-out states its mitigation"
|
||||
|
||||
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
|
||||
def test_every_carve_out_is_outside_the_audited_table(self, carve_out):
|
||||
"""A carve-out is an EXCLUSION — it must not also claim to be driven."""
|
||||
driven = {name for name, _ in AUDITED_PATHS}
|
||||
assert all(
|
||||
carve_out["name"] not in driven_path for driven_path in driven
|
||||
), carve_out["name"]
|
||||
|
||||
def test_the_pairing_probe_is_offered_only_while_the_option_is_on(self):
|
||||
"""`test_connect` has no entry to ask, so the SETUP MENU is its gate."""
|
||||
config_flow = _source("custom_components/nvidia_shield/config_flow.py")
|
||||
|
||||
menu_guard = (
|
||||
"if self._user_data.get(CONF_ADB_ENABLED) and not getattr(\n"
|
||||
' self, "_paired_adb", False\n'
|
||||
" ):\n"
|
||||
' options.append("adb_pair")'
|
||||
)
|
||||
assert menu_guard in config_flow, (
|
||||
"the adb_pair row (the only route to the pairing probe) must stay "
|
||||
"behind the setup's ADB option"
|
||||
)
|
||||
|
||||
|
||||
class TestTheLogcatPathsTheAuditNames:
|
||||
"""The audit text names these two paths — so they are DRIVEN here (t25/O2)."""
|
||||
|
||||
async def test_the_logcat_open_refuses_with_the_disabled_message(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="ADB is switched off"):
|
||||
manager._open_logcat_stream()
|
||||
|
||||
assert poller.traffic == [], "the refusal cost no traffic"
|
||||
|
||||
async def test_the_logcat_ladder_returns_without_counting_a_failure(self):
|
||||
"""A local option must not look like a device failure (t23, pinned t25)."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
await manager._run_logcat_stream() # returns at once: nothing to consume
|
||||
|
||||
assert manager._logcat_failures == [], "no failure was recorded"
|
||||
assert manager._logcat_disabled is False, "and the stream was not disabled"
|
||||
assert poller.traffic == []
|
||||
|
||||
async def test_five_real_logcat_failures_still_auto_disable(self):
|
||||
"""THE ANTI-OVERCORRECTION PIN: the ladder itself still works when ADB is on."""
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=True)
|
||||
|
||||
async def failing_pass():
|
||||
raise RuntimeError("stream ended (device closed)")
|
||||
|
||||
async def no_sleep(_seconds):
|
||||
return None
|
||||
|
||||
manager._logcat_pass = failing_pass
|
||||
manager._logcat_ladder_sleep = no_sleep
|
||||
|
||||
await manager._run_logcat_stream()
|
||||
|
||||
assert manager._logcat_disabled is True, "five real failures still trip it"
|
||||
assert manager._entry_options_logcat() is True, "the option itself is unchanged"
|
||||
|
||||
|
||||
class TestTheOneWriteWhileOffIsDebt:
|
||||
"""t25/O4: "nothing is cached" is exact because the one write is a DEBT."""
|
||||
|
||||
async def test_the_only_write_while_off_is_a_retry_debt_not_an_answer(self):
|
||||
poller = WillingPoller()
|
||||
manager = _manager(poller, adb_enabled=False)
|
||||
|
||||
# the one disabled call that reaches the probe directly (the watchdog drain
|
||||
# and the public lookup are gated above it, so this is the route the review
|
||||
# measured the write on)
|
||||
await manager._fetch_app_label(PKG)
|
||||
|
||||
assert manager._app_labels == {}, "no label was CACHED — that is the claim"
|
||||
assert manager._app_label_failed == {PKG}, "only the retry DEBT was recorded"
|
||||
assert manager.app_label(PKG) is None, "and the lookup still answers nothing"
|
||||
assert manager.app_label_inflight == set()
|
||||
assert poller.traffic == [], "the debt cost no traffic"
|
||||
|
||||
manager.entry.options[CONF_ADB_ENABLED] = True # ADB is turned back on
|
||||
await manager._fetch_app_label(PKG) # the owed retry finally runs
|
||||
|
||||
assert manager._app_labels.get(PKG) is not None, "the probe ran and cached"
|
||||
assert manager._app_label_failed == set(), "and the debt was settled"
|
||||
|
||||
|
||||
class TestAMissingOptionFailsClosed:
|
||||
"""Where the `entry=None`-means-ADB-off behaviour is asserted (t25)."""
|
||||
|
||||
async def test_an_entry_with_no_adb_option_is_disabled(self):
|
||||
"""No option recorded = OFF, so the harness change matched production."""
|
||||
poller = WillingPoller()
|
||||
entry = SimpleNamespace(options={}, data={}, title="Shield", entry_id="e1")
|
||||
manager = ShieldConnectionManager(hass=None, entry=entry, poller=poller)
|
||||
|
||||
assert manager._adb_disabled() is True
|
||||
assert manager._adb_active() is False
|
||||
assert manager.adb_poller is None
|
||||
assert await manager.send_media_command("play") is False
|
||||
assert await manager.async_refresh_installed_apps(force=True) == []
|
||||
assert manager.installed_apps() == []
|
||||
assert poller.traffic == []
|
||||
|
||||
async def test_a_manager_with_no_entry_at_all_is_disabled_too(self):
|
||||
"""The old harness shape (`entry=None`) is the same fact."""
|
||||
poller = WillingPoller()
|
||||
manager = ShieldConnectionManager(hass=None, entry=None, poller=poller)
|
||||
|
||||
assert manager._adb_disabled() is True
|
||||
assert await manager.send_digit_key("5") is False
|
||||
assert poller.traffic == []
|
||||
|
||||
def test_the_production_default_is_read_the_same_way(self):
|
||||
"""`__init__.py` gates the poller on the same missing-option default."""
|
||||
entry_source = _source("custom_components/nvidia_shield/__init__.py")
|
||||
|
||||
assert "if not entry.options.get(CONF_ADB_ENABLED, False):" in entry_source, (
|
||||
"production treats a missing option as OFF, exactly like the manager"
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,445 @@
|
||||
"""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_ADB_ENABLED,
|
||||
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]
|
||||
)
|
||||
# the harness's manager carries a realistic entry whose option says ADB is on
|
||||
assert make_manager().entry.options[CONF_ADB_ENABLED] is True
|
||||
|
||||
|
||||
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,515 @@
|
||||
"""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"
|
||||
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
|
||||
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
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,894 @@
|
||||
"""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,
|
||||
APP_LABEL_GREP_SHAPES,
|
||||
CONF_ADB_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
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 _adb_entry(**options):
|
||||
"""A realistic entry: its own option says ADB is ON (t23/O1).
|
||||
|
||||
The manager is ALWAYS built with an entry in production, and an entry whose
|
||||
options do not say ``adb_enabled: True`` means ADB is switched off — the gate
|
||||
every device path now asks directly (``connection._adb_disabled``). A harness
|
||||
that passed ``entry=None`` was therefore modelling an ADB-off entry while
|
||||
expecting traffic to flow, which is exactly the combination the t22 review
|
||||
measured. Tests that WANT the disabled behaviour build their own entry.
|
||||
"""
|
||||
return types.SimpleNamespace(
|
||||
options={CONF_ADB_ENABLED: True, **options},
|
||||
data={},
|
||||
title="Shield",
|
||||
entry_id="e1",
|
||||
)
|
||||
|
||||
|
||||
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=_adb_entry(), 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=_adb_entry(), 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 probe carries the exit marker too, so a blank answer can be told
|
||||
# from a FAILED fetch; the package is still quoted. t66: the label candidates
|
||||
# are filtered ON THE DEVICE, and the 2241-line dump is never truncated —
|
||||
# `head -300` cut it before the package section on the user's Shield.
|
||||
assert manager._poller.shell_calls == [
|
||||
f"pm dump {shlex.quote(wicked)} 2>/dev/null | "
|
||||
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
|
||||
f"echo {APP_LABEL_EXIT_MARKER}$?"
|
||||
]
|
||||
assert "head -300" not in manager._poller.shell_calls[0]
|
||||
|
||||
|
||||
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=_adb_entry(), poller=poller, clock=(clock or (lambda: now))
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": bool(lines), CONF_ADB_ENABLED: True})
|
||||
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=_adb_entry(), poller=poller, clock=lambda: 1000.0
|
||||
)
|
||||
manager.entry = LEntry(options={"logcat_events": True, CONF_ADB_ENABLED: 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=_adb_entry(), 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,740 @@
|
||||
"""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 asyncio
|
||||
import importlib
|
||||
import json
|
||||
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()),
|
||||
# t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which
|
||||
# registers a shutdown listener on ``hass.bus``. The fake hass carries
|
||||
# just enough of that surface for the real base class to construct.
|
||||
bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)),
|
||||
)
|
||||
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"),
|
||||
(
|
||||
"image",
|
||||
"NvidiaShieldScreenImage",
|
||||
{"screen_capture_enabled": True},
|
||||
"image.shield_screen_capture",
|
||||
),
|
||||
)
|
||||
|
||||
#: 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)
|
||||
|
||||
# t15: ``app_source`` names the tier that produced the app identity, so the
|
||||
# protocol push can never look like the ADB reading it outranked.
|
||||
assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
def test_the_remote_entity_publishes_the_activity_contract_under_real_ha():
|
||||
"""t15: the app/activity picker contract, measured against real HA.
|
||||
|
||||
Core androidtv_remote's remote is the parity target. HA renders the picker from
|
||||
``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``,
|
||||
published through the base class's FINAL ``state_attributes`` — so this asserts
|
||||
what the STATE MACHINE receives, not what our own attributes happen to say. The
|
||||
transport evidence rides the same entity, which is what makes "paired?" and
|
||||
"switched on?" answerable without a device test.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
from homeassistant.components.remote import RemoteEntityFeature
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"remote",
|
||||
"NvidiaShieldRemoteEntity",
|
||||
options={
|
||||
"app_name_map": {
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
}
|
||||
},
|
||||
entity_id="remote.shield",
|
||||
)
|
||||
|
||||
assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's
|
||||
assert entity.activity_list == ["Netflix", "TiviMate"]
|
||||
assert entity.current_activity is None, "nothing is known yet — never fabricated"
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate"]
|
||||
assert attrs["current_activity"] is None
|
||||
# ...and the transport's real state, on the same entity (no runtime_data here,
|
||||
# so there is no transport at all — which must read as "off", not as unpaired)
|
||||
assert attrs["protocol_state"] == "off"
|
||||
assert attrs["protocol_connected"] is False
|
||||
assert attrs["protocol_ever_authenticated"] is False
|
||||
assert attrs["power_on_route"] == "protocol"
|
||||
assert attrs["protocol_current_app_seen"] is False
|
||||
|
||||
# The LIVE update paths our entity uses: HA's own metaclass turns the class-level
|
||||
# ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache
|
||||
# invalidation, so both assignments must take effect on the published state. A
|
||||
# private attribute name would leave ``activity_list``/``current_activity`` frozen
|
||||
# at their first value — silent in a unit test, wrong in a dashboard.
|
||||
entity._attr_current_activity = "Netflix"
|
||||
entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
assert entity.current_activity == "Netflix"
|
||||
assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert attrs["current_activity"] == "Netflix"
|
||||
assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"]
|
||||
|
||||
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
|
||||
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
|
||||
_SCREEN_PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
|
||||
"Af+4BDXgAAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
def test_the_screen_capture_entity_serves_png_bytes_under_real_ha():
|
||||
"""t66: HA's OWN image pieces accept what the capture path produces.
|
||||
|
||||
The stub suite proves the logic; this proves the contract with real HA: the
|
||||
real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image
|
||||
proxy runs before it will serve bytes), and the real state write — whose
|
||||
recorded state must be the capture TIMESTAMP and whose attributes must never
|
||||
carry the image itself (the "never a state source" boundary, measured).
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
import base64
|
||||
|
||||
from homeassistant.components.image import valid_image_content_type
|
||||
|
||||
class _Poller:
|
||||
connected = True
|
||||
|
||||
async def shell(self, cmd):
|
||||
assert cmd == "screencap -p | base64"
|
||||
return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"image",
|
||||
"NvidiaShieldScreenImage",
|
||||
options={"screen_capture_enabled": True},
|
||||
entity_id="image.shield_screen_capture",
|
||||
)
|
||||
entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller())
|
||||
|
||||
data = asyncio.run(entity.async_image())
|
||||
|
||||
assert isinstance(data, bytes)
|
||||
assert data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
assert valid_image_content_type(entity.content_type) == "image/png"
|
||||
|
||||
written_id, state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert written_id == "image.shield_screen_capture"
|
||||
# HA's image state IS ``image_last_updated`` — the timestamp, never the bytes
|
||||
assert state == entity.image_last_updated.isoformat()
|
||||
assert attrs["capture_status"] == "ok"
|
||||
assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values())
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------- t63: the media-browser root (real HA)
|
||||
#
|
||||
# The live report ("Browse media seems to spin and never finish loading") came
|
||||
# from what async_browse_media HANDED BACK, not from the handler: our method
|
||||
# returns with no awaits, so nothing inside it can hang. HA's handler only
|
||||
# forwards the payload, which makes the defect visible on one path only:
|
||||
#
|
||||
# websocket_browse_media -> player.async_browse_media -> payload.as_dict()
|
||||
#
|
||||
# The frontend bundle is NOT installed here (no hass_frontend, and there is no
|
||||
# browser to drive), so the rendered dialog itself cannot be exercised. What
|
||||
# these tests prove is that backend path end to end, decorators included. The
|
||||
# pre-fix payload carried ``media_content_id=None`` — a node shape no core
|
||||
# integration ever serves, and the assertions below fail on it.
|
||||
|
||||
|
||||
class _RecordingConnection:
|
||||
"""The pieces of websocket_api.ActiveConnection the browse handler calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.results: list = []
|
||||
self.messages: list = []
|
||||
self.exceptions: list = []
|
||||
|
||||
def send_result(self, msg_id, result):
|
||||
self.results.append((msg_id, result))
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def async_handle_exception(self, msg, err):
|
||||
self.exceptions.append(err)
|
||||
|
||||
|
||||
def _wire_the_browser(entity):
|
||||
"""Give the entity's own hass what websocket_browse_media walks.
|
||||
|
||||
``hass.data[DATA_COMPONENT].get_entity`` is how the handler finds the player,
|
||||
and ``async_create_background_task`` is how ``@websocket_api.async_response``
|
||||
schedules the real coroutine — both real shapes, so the DECORATED handler
|
||||
runs unmodified.
|
||||
"""
|
||||
from homeassistant.components.media_player import DATA_COMPONENT
|
||||
|
||||
scheduled: list = []
|
||||
|
||||
def async_create_background_task(coro, name, *, eager_start=False):
|
||||
task = asyncio.create_task(coro)
|
||||
scheduled.append(task)
|
||||
return task
|
||||
|
||||
entity.hass.data[DATA_COMPONENT] = SimpleNamespace(
|
||||
get_entity=lambda entity_id: entity if entity_id == entity.entity_id else None
|
||||
)
|
||||
entity.hass.async_create_background_task = async_create_background_task
|
||||
return scheduled
|
||||
|
||||
|
||||
async def _browse_over_the_real_handler(entity, scheduled, *, content_id=None,
|
||||
content_type=None):
|
||||
"""Send the real websocket command and await the task it schedules."""
|
||||
from homeassistant.components.media_player import websocket_browse_media
|
||||
|
||||
connection = _RecordingConnection()
|
||||
msg = {"id": 7, "type": "media_player/browse_media", "entity_id": entity.entity_id}
|
||||
if content_id is not None or content_type is not None:
|
||||
msg["media_content_id"] = content_id
|
||||
msg["media_content_type"] = content_type
|
||||
|
||||
websocket_browse_media(entity.hass, connection, msg) # schedules the coroutine
|
||||
await asyncio.gather(*scheduled)
|
||||
|
||||
assert connection.exceptions == [], connection.exceptions
|
||||
assert connection.messages == [], connection.messages
|
||||
assert len(connection.results) == 1, connection.results
|
||||
return connection.results[0][1]
|
||||
|
||||
|
||||
async def test_the_real_websocket_handler_serves_a_string_id_root():
|
||||
"""t63 regression: the browse root carries a real STRING id, not None.
|
||||
|
||||
Every core media_player platform serves a string id (a literal, a module
|
||||
constant or ""), because the frontend keys the node it is loading on that id;
|
||||
the live dialog re-requested the same node for ever with a null one.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, _states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
scheduled = _wire_the_browser(entity)
|
||||
|
||||
payload = await _browse_over_the_real_handler(entity, scheduled)
|
||||
|
||||
assert isinstance(payload["media_content_id"], str)
|
||||
assert payload["media_content_id"] == "root"
|
||||
assert payload["media_content_type"] == "directory" # not MediaType.APPS
|
||||
assert payload["children"] == []
|
||||
assert payload["can_expand"] is False
|
||||
assert payload["can_play"] is False
|
||||
assert payload["media_class"] == "directory"
|
||||
# the payload the frontend receives has to survive the websocket's own encode
|
||||
assert json.loads(json.dumps(payload)) == payload
|
||||
|
||||
|
||||
async def test_the_real_handler_answers_every_id_with_the_same_root():
|
||||
"""Idempotent: the root id, None and a stale child id give ONE payload.
|
||||
|
||||
That is the property that stops a frontend bouncing between "loading" and
|
||||
"loaded" for one id — browse(root_id) must be browse(None) in shape.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, _states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
scheduled = _wire_the_browser(entity)
|
||||
|
||||
first = await _browse_over_the_real_handler(entity, scheduled)
|
||||
root_id = first["media_content_id"]
|
||||
|
||||
for content_type, content_id in (
|
||||
(None, None),
|
||||
("apps", root_id),
|
||||
("apps", "apps"),
|
||||
("app", "ar.tvplayer.tv"),
|
||||
):
|
||||
repeat = await _browse_over_the_real_handler(
|
||||
entity, scheduled, content_id=content_id, content_type=content_type
|
||||
)
|
||||
|
||||
assert repeat == first
|
||||
@@ -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,300 @@
|
||||
"""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}
|
||||
#: t14: per-service response support the registration declared.
|
||||
self.response_support: dict[tuple, object] = {}
|
||||
super().__init__(
|
||||
config_entries=config_entries,
|
||||
scheduled_tasks=[],
|
||||
services=self,
|
||||
)
|
||||
|
||||
def async_register(
|
||||
self, domain, service, func, schema=None, supports_response=None
|
||||
):
|
||||
self._services[(domain, service)] = (func, schema)
|
||||
#: t14: HA is told whether the action answers with a response.
|
||||
self.response_support[(domain, service)] = supports_response
|
||||
|
||||
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"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,494 @@
|
||||
"""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
|
||||
|
||||
# t66: the curated table grew to cover the apps the report listed, and the
|
||||
# dropdown is the same table (so it is asserted against the table itself,
|
||||
# not a copy that would drift)
|
||||
from nsh.const import DEFAULT_APP_DIRECTORY
|
||||
|
||||
assert player.source_list == list(DEFAULT_APP_DIRECTORY.values())
|
||||
assert "Netflix" in player.source_list # a name the user recognises
|
||||
|
||||
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_the_chain(self, modules):
|
||||
"""Blanks are not displayed as nothing — the chain continues past them (t66).
|
||||
|
||||
Past a blank map value comes the device label, then the curated table, then
|
||||
the raw id: `ar.tvplayer.tv` is curated, so the dropdown row is its NAME.
|
||||
The raw-id tail of the same chain is pinned in the test below.
|
||||
"""
|
||||
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
|
||||
|
||||
assert player.source == "TiviMate"
|
||||
|
||||
def test_a_package_nothing_names_stays_raw(self, modules):
|
||||
"""The last tier: no map value, no device label, no curated row → the id.
|
||||
|
||||
It is shown ONCE (the id itself), never as a `pkg=pkg` pair.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules, app_map={"com.example.unknown": ""}, foreground="com.example.unknown"
|
||||
)
|
||||
|
||||
assert player.source == "com.example.unknown"
|
||||
assert player.source_list == [] # a name is the ticker's raw id
|
||||
@@ -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,175 @@
|
||||
"""tests/test_now_playing_publication.py — which entity states may carry a reading (t17/O2).
|
||||
|
||||
THE OBSERVATION THIS CLOSES. The batch review read `extra_state_attributes` and
|
||||
argued the now-playing row publishes "the entity state verbatim", so a dashboard
|
||||
could see `now_playing_state: on`, while the README documents only `playing`,
|
||||
`paused`, `buffering` and `idle`. t10's author had reported the opposite (an
|
||||
`on` or `off` device publishes no row at all). Both accounts were about a
|
||||
different thing, and this file settles it BY EXECUTION:
|
||||
|
||||
* the t10 account is true for the NATURAL ladder — `on` means no source reported
|
||||
a power or playback fact, and a reading only exists when a tier answered, so
|
||||
the two cannot meet in practice;
|
||||
* the review was right about the RULE, which was "publish unless the state is
|
||||
`off`" — an unenforced assumption, and the probe below shows it publishing
|
||||
`now_playing_state: on` (and `standby`) the moment a reading exists beside that
|
||||
state. `standby` is not even in the README, and nothing in the code stopped it.
|
||||
|
||||
WHAT WAS CHANGED. The publication rule now names the four values a reading may
|
||||
carry (`NOW_PLAYING_STATE_VALUES`) and drops the reading when the entity state is
|
||||
anything else, exactly as a state of `off` always did. The README says the same
|
||||
thing in words. The two tests here walk EVERY ``MediaPlayerState`` member and
|
||||
compare the README against the code constant, so neither can drift again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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 MediaPlayerState, load_entity_modules # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + the loaded entity modules (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
yield load_entity_modules()
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
#: The states the README documents, read from the file itself (never from the
|
||||
#: constant under test) so a code change cannot silently rewrite the pin.
|
||||
def _documented_states() -> set[str]:
|
||||
text = README.read_text(encoding="utf-8")
|
||||
row = re.search(r"^\| `now_playing_state` \|(.*)\|$", text, re.M)
|
||||
assert row, "the README's now_playing_state table row is gone"
|
||||
return {value for value in re.findall(r"`([a-z_]+)`", row.group(1)) if value != "on"}
|
||||
|
||||
|
||||
def _player_for_state(modules, member):
|
||||
"""A player whose ladder answers `member` while a reading is available.
|
||||
|
||||
The state is forced by patching the ``state`` property, which is the only way
|
||||
to ask "what would be published IF the ladder ever answered this?" — the
|
||||
question the review raised. A reading is genuine: the foreground tier answers
|
||||
it through the same code path production uses (``_now_playing``).
|
||||
"""
|
||||
from test_command_routing import TIVIMATE_PACKAGE, _now_playing_attrs, _state_player
|
||||
|
||||
player, _manager = _state_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||
player._last_channel = "101"
|
||||
media_player_cls = type(player)
|
||||
original = media_player_cls.__dict__.get("state")
|
||||
media_player_cls.state = property(lambda self, forced=member: forced)
|
||||
try:
|
||||
reading = player._now_playing()
|
||||
attrs = _now_playing_attrs(player)
|
||||
finally:
|
||||
if original is None: # pragma: no cover - the class always declares it
|
||||
del media_player_cls.state
|
||||
else:
|
||||
media_player_cls.state = original
|
||||
return reading, attrs
|
||||
|
||||
|
||||
class TestThePublicationRule:
|
||||
"""One reading in, one row out — for EVERY member the enum has."""
|
||||
|
||||
def test_the_documented_four_publish_the_reading(self, scoped_entity_stubs):
|
||||
modules = scoped_entity_stubs
|
||||
documented = {
|
||||
MediaPlayerState.PLAYING: "playing",
|
||||
MediaPlayerState.PAUSED: "paused",
|
||||
MediaPlayerState.BUFFERING: "buffering",
|
||||
MediaPlayerState.IDLE: "idle",
|
||||
}
|
||||
|
||||
for member, value in documented.items():
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
|
||||
assert reading, f"{value}: the fixture must have a reading to publish"
|
||||
assert attrs["now_playing_state"] == value
|
||||
assert attrs["now_playing_title"] == "101"
|
||||
|
||||
def test_a_state_outside_the_documented_four_publishes_nothing(self, scoped_entity_stubs):
|
||||
"""The review's case, executed: `on` publishes no row — by construction now."""
|
||||
modules = scoped_entity_stubs
|
||||
reading_available = {
|
||||
MediaPlayerState.ON: "on",
|
||||
MediaPlayerState.OFF: "off",
|
||||
MediaPlayerState.STANDBY: "standby",
|
||||
}
|
||||
|
||||
for member, value in reading_available.items():
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
|
||||
assert reading, f"{value}: the fixture still HAS a reading (that is the point)"
|
||||
assert attrs == {}, f"{value} must publish no now-playing row, got {attrs}"
|
||||
|
||||
def test_every_enum_member_is_accounted_for(self, scoped_entity_stubs):
|
||||
"""The matrix is exhaustive: no state is left untested as the enum grows."""
|
||||
modules = scoped_entity_stubs
|
||||
published = {}
|
||||
for member in MediaPlayerState:
|
||||
reading, attrs = _player_for_state(modules, member)
|
||||
published[member.value] = attrs.get("now_playing_state")
|
||||
|
||||
assert set(published) == {member.value for member in MediaPlayerState}
|
||||
for value in ("playing", "paused", "buffering", "idle"):
|
||||
assert published[value] == value
|
||||
for value in ("on", "off", "standby"):
|
||||
assert published[value] is None, f"{value} published a state: {published[value]}"
|
||||
|
||||
def test_the_gate_reads_the_named_constant(self, scoped_entity_stubs):
|
||||
"""The rule is the constant, not a hard-coded pair of states."""
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
assert NOW_PLAYING_STATE_VALUES == ("playing", "paused", "buffering", "idle")
|
||||
assert self._published_for(scoped_entity_stubs, MediaPlayerState.ON) == {}
|
||||
|
||||
@staticmethod
|
||||
def _published_for(modules, member) -> dict:
|
||||
_reading, attrs = _player_for_state(modules, member)
|
||||
return attrs
|
||||
|
||||
|
||||
class TestTheReadmeAgrees:
|
||||
"""The docs and the code say the same four values (t17/O2's anti-drift pin)."""
|
||||
|
||||
def test_the_table_row_lists_exactly_the_constant(self, scoped_entity_stubs):
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
assert _documented_states() == set(NOW_PLAYING_STATE_VALUES)
|
||||
|
||||
def test_the_prose_names_the_same_set_and_excludes_on(self, scoped_entity_stubs):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
from nsh.media_player import NOW_PLAYING_STATE_VALUES
|
||||
|
||||
paragraph = re.search(
|
||||
r"\*\*`now_playing_state` is one of FOUR values[^*]*\*\*(.*?)\n-", text, re.S
|
||||
)
|
||||
assert paragraph, "the README's now_playing_state paragraph is gone"
|
||||
body = paragraph.group(1)
|
||||
# the sentence that names the set, read apart from the sentence that
|
||||
# excludes `on`/`off` — otherwise the exclusion would look like a member
|
||||
sentence = re.search(r"full set is ([^.]+)\.", body)
|
||||
assert sentence, "the README no longer names the full set"
|
||||
named = set(re.findall(r"`([a-z_]+)`", sentence.group(1)))
|
||||
assert named == set(NOW_PLAYING_STATE_VALUES), named
|
||||
assert "`on`" in body and "not one of them" in text, (
|
||||
"the paragraph must say `on` is excluded"
|
||||
)
|
||||
|
||||
def test_the_off_and_on_prose_still_says_no_rows(self, scoped_entity_stubs):
|
||||
text = README.read_text(encoding="utf-8")
|
||||
|
||||
assert "**A device that reads `off` publishes none of them.**" in text
|
||||
assert "publishes none either" in text
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ── t17: the intents documentation ──
|
||||
|
||||
#: The keys a seeded intent row may carry (the seed validator in config_flow.py
|
||||
#: decides what it ACCEPTS; these are the names the README may use).
|
||||
INTENT_ROW_KEYS = {
|
||||
"id",
|
||||
"name",
|
||||
"package",
|
||||
"activity",
|
||||
"action",
|
||||
"categories",
|
||||
"data",
|
||||
"fields",
|
||||
}
|
||||
|
||||
#: The keys one declared field may carry.
|
||||
INTENT_FIELD_KEYS = {"key", "type", "required", "default"}
|
||||
|
||||
|
||||
def _intents_example(text: str) -> str:
|
||||
"""The `registered_intents:` block of the README's seed example (t17)."""
|
||||
match = re.search(
|
||||
r"```yaml\n(?:.*\n)*? registered_intents:\n((?:.*\n)*?)```", text
|
||||
)
|
||||
assert match, "the README has no `registered_intents:` example block"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_the_intents_example_only_uses_keys_the_seed_honours(readme_text):
|
||||
"""Documented-but-ignored is the failure this pins (t30's rule, nested)."""
|
||||
block = _intents_example(readme_text)
|
||||
row_keys = set(re.findall(r"^\s*([a-z_]+):", block, re.M))
|
||||
field_keys = set(re.findall(r"[{,]\s*([a-z_]+):", block))
|
||||
|
||||
assert row_keys, "the intents example declares no row keys"
|
||||
assert row_keys <= INTENT_ROW_KEYS, sorted(row_keys - INTENT_ROW_KEYS)
|
||||
assert field_keys <= INTENT_ROW_KEYS | INTENT_FIELD_KEYS, sorted(
|
||||
field_keys - INTENT_ROW_KEYS - INTENT_FIELD_KEYS
|
||||
)
|
||||
|
||||
|
||||
def test_the_intents_screen_and_action_are_documented(readme_text):
|
||||
"""The screen, its fields, the button and the action are all named (t17)."""
|
||||
for needle in (
|
||||
"### Intents (buttons and the `send_intent` action)",
|
||||
"Configure → **Intents**",
|
||||
"Variables (declared fields)",
|
||||
"One button per registered intent",
|
||||
"nvidia_shield.send_intent",
|
||||
"intent_response",
|
||||
):
|
||||
assert needle in readme_text, needle
|
||||
|
||||
|
||||
def test_the_intents_copy_states_the_honest_failures(readme_text):
|
||||
"""The README names each way this can go wrong — and that it RAISES (t17)."""
|
||||
for key in (
|
||||
"unknown_intent",
|
||||
"missing_intent_field",
|
||||
"unknown_intent_variable",
|
||||
"invalid_intent_field",
|
||||
"intent_failed",
|
||||
"intents_need_adb",
|
||||
):
|
||||
assert key in readme_text, key
|
||||
assert "configuration problem" in readme_text
|
||||
# the sentence wraps across lines in the source, so compare it flattened
|
||||
flat = " ".join(readme_text.split())
|
||||
assert "no `eval`, no `format()`, no shell interpolation" in flat
|
||||
@@ -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"
|
||||
@@ -0,0 +1,383 @@
|
||||
"""t66: the opt-in screen-capture IMAGE entity.
|
||||
|
||||
WHAT IS PROVEN HERE, AND WHAT IS NOT
|
||||
------------------------------------
|
||||
Proven: the bytes that reach Home Assistant are REAL PNG bytes — ``PNG_B64``
|
||||
below is a real 2x1 PNG, base64'd on the wire exactly the way
|
||||
``screencap -p | base64`` sends it — the entity hands HA ``bytes`` and never a
|
||||
``str``, a disconnected link or a bad answer makes the entity UNAVAILABLE
|
||||
instead of serving a blank/zero-byte image, one capture is coalesced and reused
|
||||
so a dashboard cannot hammer the device, and the option defaults OFF so no
|
||||
entity exists until the user opts in.
|
||||
|
||||
NOT proven: anything about a real Shield. No device was available in this
|
||||
environment, so ``screencap``'s flags, the device's base64 and — the important
|
||||
one — how the persistent ADB socket copes with a few hundred kilobytes of
|
||||
base64 in a single ``shell()`` call are INFERRED, not measured. ``FakeAdbPoller``
|
||||
answers the way a working Shield should.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
from entity_stublib import install, load_entity_modules
|
||||
|
||||
#: A REAL 2x1 PNG (one red pixel, one green pixel) — 72 bytes, base64. It is the
|
||||
#: wire form the device sends, so the fixture exercises the real decode path
|
||||
#: rather than an invented one.
|
||||
PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
|
||||
"Af+4BDXgAAAAAElFTkSuQmCC"
|
||||
)
|
||||
PNG_BYTES = base64.b64decode(PNG_B64)
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def wrapped(text: str, width: int = 76) -> str:
|
||||
"""``base64``'s own line wrapping — the device wraps, and that is not corruption."""
|
||||
return "\n".join(text[i : i + width] for i in range(0, len(text), width)) + "\n"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
install()
|
||||
yield load_entity_modules()
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture(name="modules")
|
||||
def load_modules_fixture():
|
||||
return load_entity_modules()
|
||||
|
||||
|
||||
class FakeAdbPoller:
|
||||
"""The ADB seam the entity is allowed to use: ``connected`` + ``shell``."""
|
||||
|
||||
def __init__(self, answer=None, *, connected=True, delay=0.0):
|
||||
self.connected = connected
|
||||
self.answer = answer
|
||||
self.delay = delay
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, cmd: str):
|
||||
self.calls.append(cmd)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if isinstance(self.answer, Exception):
|
||||
raise self.answer
|
||||
if callable(self.answer):
|
||||
return self.answer(cmd)
|
||||
return self.answer
|
||||
|
||||
|
||||
class StrictManager:
|
||||
"""Only ``adb_poller`` may be touched — nothing else exists on purpose.
|
||||
|
||||
"Never a state source" is a claim about what the capture path reads and
|
||||
writes: it reads the ADB poller for a screen grab and writes nothing at all.
|
||||
A manager that raises on every other attribute turns a future reach-in (a
|
||||
store write, a state push) into a failing test instead of a quiet coupling
|
||||
between screen capture and the state pipeline.
|
||||
"""
|
||||
|
||||
def __init__(self, poller):
|
||||
self.adb_poller = poller
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AssertionError(f"the screen-capture path touched manager.{name}")
|
||||
|
||||
|
||||
def make_entry(options=None, *, entry_id="e1", title="Shield"):
|
||||
return SimpleNamespace(
|
||||
entry_id=entry_id, title=title, options=dict(options or {}), runtime_data=None
|
||||
)
|
||||
|
||||
|
||||
def make_entity(modules, poller, options=None):
|
||||
"""The entity over a fake manager on the entry, plus the entry it belongs to."""
|
||||
entry = make_entry(options or {"screen_capture_enabled": True})
|
||||
if poller is not None:
|
||||
entry.runtime_data = StrictManager(poller)
|
||||
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
|
||||
return entity, entry
|
||||
|
||||
|
||||
def fake_clock(modules, monkeypatch, start=1000.0):
|
||||
"""Freeze ``time.monotonic`` for the capture-window tests. Returns the cell."""
|
||||
now = [start]
|
||||
monkeypatch.setattr(
|
||||
modules["image"], "time", SimpleNamespace(monotonic=lambda: now[0])
|
||||
)
|
||||
return now
|
||||
|
||||
|
||||
# ── the decode path ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureDecode:
|
||||
"""``decode_screen_capture`` — the one place an image could be corrupted."""
|
||||
|
||||
def test_a_real_png_survives_the_wire_format(self, modules):
|
||||
decode = modules["image"].decode_screen_capture
|
||||
|
||||
data = decode(wrapped(PNG_B64))
|
||||
|
||||
assert data == PNG_BYTES # byte-for-byte, wrapping and all
|
||||
assert data.startswith(PNG_SIGNATURE)
|
||||
assert isinstance(data, bytes) # not a str: this is base64-decoded
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"answer",
|
||||
[
|
||||
None, # the transport never answered (AdbClientBase.shell's failure)
|
||||
"", # an empty answer is not an image
|
||||
"not base64 at all !!",
|
||||
base64.b64encode(b"hello world, definitely not a png").decode(),
|
||||
# a signature and nothing else: not a PNG (the IHDR chunk is missing)
|
||||
base64.b64encode(PNG_SIGNATURE + b"\x00" * 8).decode(),
|
||||
],
|
||||
)
|
||||
def test_everything_that_is_not_a_png_is_rejected(self, modules, answer):
|
||||
assert modules["image"].decode_screen_capture(answer) is None
|
||||
|
||||
def test_a_device_error_line_is_not_an_image(self, modules):
|
||||
polluted = "error: no display\n" + wrapped(PNG_B64)
|
||||
|
||||
assert modules["image"].decode_screen_capture(polluted) is None
|
||||
|
||||
|
||||
# ── the entity ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureEntity:
|
||||
async def test_the_entity_serves_real_png_bytes_and_never_a_str(self, modules):
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
data = await entity.async_image()
|
||||
|
||||
assert data == PNG_BYTES
|
||||
assert data.startswith(PNG_SIGNATURE)
|
||||
assert not isinstance(data, str)
|
||||
assert entity.available is True
|
||||
assert entity.capture_status == "ok"
|
||||
assert entity.extra_state_attributes["capture_status"] == "ok"
|
||||
|
||||
async def test_the_command_is_the_base64_route_and_needs_no_device_file(self, modules):
|
||||
"""Route 2 of the module docstring, and the reason it was chosen."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
|
||||
assert poller.calls == ["screencap -p | base64"]
|
||||
# No device-side artifact exists, so there is nothing to clean up...
|
||||
assert "/sdcard" not in poller.calls[0]
|
||||
assert "rm " not in poller.calls[0]
|
||||
|
||||
async def test_a_disconnected_link_is_unavailable_and_is_never_asked(self, modules):
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64), connected=False)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "adb_disconnected"
|
||||
assert await entity.async_image() is None
|
||||
assert poller.calls == [] # a dead link is not poked for a screenshot
|
||||
|
||||
async def test_adb_disabled_has_its_own_reason(self, modules):
|
||||
entry = make_entry({"screen_capture_enabled": True})
|
||||
manager = SimpleNamespace(adb_poller=None)
|
||||
entry.runtime_data = manager
|
||||
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
|
||||
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "adb_disabled"
|
||||
assert await entity.async_image() is None
|
||||
|
||||
async def test_an_empty_answer_is_unavailable_not_a_blank_image(self, modules):
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "capture_failed"
|
||||
|
||||
async def test_a_transport_that_raises_is_a_failure_not_a_crash(self, modules):
|
||||
poller = FakeAdbPoller(RuntimeError("socket closed under us"))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
assert entity.capture_status == "capture_failed"
|
||||
|
||||
async def test_a_failed_capture_recovers_on_the_next_request(self, modules):
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.available is False
|
||||
|
||||
poller.answer = wrapped(PNG_B64) # the device came back
|
||||
|
||||
assert await entity.async_image() == PNG_BYTES
|
||||
assert entity.available is True
|
||||
assert entity.capture_status == "ok"
|
||||
|
||||
async def test_one_capture_is_reused_inside_the_window(self, modules, monkeypatch):
|
||||
"""A dashboard repaint is not a screen grab per repaint."""
|
||||
now = fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
first = await entity.async_image()
|
||||
now[0] += modules["image"].CAPTURE_REUSE_SECONDS - 1.0
|
||||
second = await entity.async_image()
|
||||
|
||||
assert first == second == PNG_BYTES
|
||||
assert len(poller.calls) == 1 # one shell, two image requests
|
||||
|
||||
async def test_the_window_expires_so_a_later_view_refreshes(self, modules, monkeypatch):
|
||||
now = fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
now[0] += modules["image"].CAPTURE_REUSE_SECONDS + 1.0
|
||||
await entity.async_image()
|
||||
|
||||
assert len(poller.calls) == 2
|
||||
|
||||
async def test_an_explicit_refresh_skips_the_reuse_window(self, modules, monkeypatch):
|
||||
"""``homeassistant.update_entity`` means "grab it now"."""
|
||||
fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
await entity.async_image()
|
||||
await entity.async_update()
|
||||
|
||||
assert len(poller.calls) == 2
|
||||
|
||||
async def test_concurrent_requests_share_one_capture(self, modules):
|
||||
"""Parallel image fetches must not leak ADB shells."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64), delay=0.01)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
results = await asyncio.gather(
|
||||
entity.async_image(), entity.async_image(), entity.async_image()
|
||||
)
|
||||
|
||||
assert list(results) == [PNG_BYTES, PNG_BYTES, PNG_BYTES]
|
||||
assert len(poller.calls) == 1
|
||||
|
||||
async def test_capture_reads_the_adb_poller_and_drives_nothing(self, modules):
|
||||
"""The strict manager raises if the capture path touches anything else."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, _entry = make_entity(modules, poller) # StrictManager
|
||||
|
||||
assert await entity.async_image() == PNG_BYTES
|
||||
|
||||
# ...and the state HA would record is the timestamp, never the bytes
|
||||
assert entity.image_last_updated is not None
|
||||
assert isinstance(entity.image_last_updated, datetime)
|
||||
assert entity.image_last_updated.tzinfo is not None
|
||||
|
||||
async def test_image_last_updated_is_stamped_only_by_a_success(self, modules, monkeypatch):
|
||||
fake_clock(modules, monkeypatch)
|
||||
poller = FakeAdbPoller(None)
|
||||
entity, _entry = make_entity(modules, poller)
|
||||
|
||||
assert entity.image_last_updated is None # nothing captured yet
|
||||
|
||||
assert await entity.async_image() is None
|
||||
assert entity.image_last_updated is None # a failure stamps nothing
|
||||
|
||||
poller.answer = wrapped(PNG_B64)
|
||||
await entity.async_image()
|
||||
stamped = entity.image_last_updated
|
||||
|
||||
assert stamped is not None
|
||||
poller.answer = None # a later failure must not move it
|
||||
await entity.async_image()
|
||||
assert entity.image_last_updated is stamped
|
||||
|
||||
def test_entity_hygiene(self, modules):
|
||||
"""Naming, content type, recorder hygiene, and the no-poll rule."""
|
||||
poller = FakeAdbPoller(wrapped(PNG_B64))
|
||||
entity, entry = make_entity(modules, poller)
|
||||
cls = type(entity)
|
||||
|
||||
assert cls._attr_has_entity_name is True
|
||||
assert entity.translation_key == "screen_capture"
|
||||
assert entity.content_type == "image/png"
|
||||
# HA polls image entities never by default; this one must not either
|
||||
assert cls._attr_should_poll is False
|
||||
assert entity.should_poll is False
|
||||
assert entity.unique_id == f"nvidia_shield_{entry.entry_id}_screen_capture"
|
||||
assert entity.device_info["identifiers"] == {("nvidia_shield", entry.entry_id)}
|
||||
# the status changes with a capture, so it stays out of the database, and
|
||||
# no attribute ever carries the image bytes
|
||||
assert "capture_status" in cls._unrecorded_attributes
|
||||
for value in entity.extra_state_attributes.values():
|
||||
assert not isinstance(value, (bytes, bytearray))
|
||||
|
||||
def test_the_platform_is_forwarded(self, modules):
|
||||
"""The new platform is in the entry's list, and the manifest still parses."""
|
||||
platforms = [str(p) for p in sys.modules["nsh.init"].PLATFORMS]
|
||||
|
||||
assert "image" in platforms
|
||||
assert platforms.count("image") == 1
|
||||
|
||||
manifest = json.loads(
|
||||
(_SHIELD_DIR / "manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
# HA discovers platforms from the forwarded list; a "platforms" key in the
|
||||
# manifest is not a thing (and adding one would be a silent no-op).
|
||||
assert "platforms" not in manifest
|
||||
assert "adb-shell>=0.4.0" in manifest["requirements"]
|
||||
|
||||
|
||||
# ── opt-in ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScreenCaptureOptIn:
|
||||
"""Default OFF: no option, no entity, no cost."""
|
||||
|
||||
async def _setup(self, modules, options):
|
||||
added: list = []
|
||||
entry = make_entry(options)
|
||||
entry.runtime_data = StrictManager(FakeAdbPoller(wrapped(PNG_B64)))
|
||||
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
|
||||
return added
|
||||
|
||||
async def test_the_option_defaults_off_and_creates_no_entity(self, modules):
|
||||
assert await self._setup(modules, {}) == []
|
||||
|
||||
assert sys.modules["nsh.const"].CONF_SCREEN_CAPTURE_ENABLED == (
|
||||
"screen_capture_enabled"
|
||||
)
|
||||
assert await self._setup(modules, {"screen_capture_enabled": False}) == []
|
||||
|
||||
async def test_switching_it_on_creates_exactly_one_entity(self, modules):
|
||||
added = await self._setup(modules, {"screen_capture_enabled": True})
|
||||
|
||||
assert len(added) == 1
|
||||
assert type(added[0]).__name__ == "NvidiaShieldScreenImage"
|
||||
|
||||
async def test_an_entry_without_a_manager_is_skipped_not_crashed(self, modules):
|
||||
added: list = []
|
||||
entry = make_entry({"screen_capture_enabled": True})
|
||||
entry.runtime_data = None
|
||||
|
||||
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
|
||||
|
||||
assert added == []
|
||||
@@ -0,0 +1,478 @@
|
||||
"""t17: `nvidia_shield.send_intent` against a REAL Home Assistant install.
|
||||
|
||||
The intents feature has one claim a stub cannot settle: that the action RETURNS
|
||||
the device's output, and that the frontend is offered the registered intents as a
|
||||
dropdown. Both are decided by Home Assistant itself — the response flag comes from
|
||||
``async_set_service_schema`` reading ``hass.services.supports_response()``, and the
|
||||
dropdown is whatever description the websocket command serves — so this file
|
||||
drives the installed HA (2026.10.1) end to end:
|
||||
|
||||
* the REAL service registry: the registration ``async_setup`` makes, its schema,
|
||||
and ``SupportsResponse.OPTIONAL`` (a *required* response would break every
|
||||
caller that does not ask for one);
|
||||
* the REAL ``get_services`` payload — the exact JSON the frontend parses — so the
|
||||
intent dropdown and the entity target picker are proven where they are consumed;
|
||||
* the REAL ``hass.services.async_call`` with ``return_response=True``: the output
|
||||
comes back under ``intent_response``, and the command that reached the fake ADB
|
||||
surface is asserted verbatim;
|
||||
* the REAL exception machinery: an unknown intent, a refused intent and ADB being
|
||||
down each raise ``ServiceValidationError`` with our translation key and a usable
|
||||
message (never a silent success, never an empty response).
|
||||
|
||||
WHAT THIS FILE CANNOT PROVE: there is no Shield and no browser here. The command
|
||||
string is asserted against the fake poller (the real ADB transport is t13/t14's
|
||||
subject), and "how the dropdown LOOKS" is the frontend's business — the wire
|
||||
contract is what this file pins. Every test skips cleanly when homeassistant is
|
||||
not installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
pytest.importorskip(
|
||||
"homeassistant",
|
||||
reason="the real service registry is the point of this file",
|
||||
)
|
||||
|
||||
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
|
||||
from homeassistant.config_entries import ( # noqa: E402
|
||||
SOURCE_USER,
|
||||
ConfigEntries,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant # noqa: E402
|
||||
from homeassistant.exceptions import ServiceValidationError # 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,
|
||||
)
|
||||
from homeassistant.helpers.service import ( # noqa: E402
|
||||
async_get_all_descriptions,
|
||||
)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
SERVICE = "send_intent"
|
||||
ENTITY_ID = "media_player.shield"
|
||||
|
||||
#: The registration this file sends: one component, one action, one declared
|
||||
#: variable used by the data template, and one integer with a default.
|
||||
NETFLIX_INTENT = {
|
||||
"id": "netflix_title",
|
||||
"name": "Netflix: open a title",
|
||||
"package": "com.netflix.ninja",
|
||||
"activity": "com.netflix.ninja/.MainActivity",
|
||||
"action": "android.intent.action.VIEW",
|
||||
"categories": ["android.intent.category.DEFAULT"],
|
||||
"data": "https://www.netflix.com/watch/{id}",
|
||||
"fields": [
|
||||
{"key": "id", "type": "string", "required": True},
|
||||
{"key": "resume", "type": "bool", "default": "true"},
|
||||
],
|
||||
}
|
||||
|
||||
#: What the handler must build for ``{"id": "80100172"}`` — asserted verbatim, so
|
||||
#: a change to the flag order or the quoting is a test failure, not a surprise.
|
||||
EXPECTED_COMMAND = (
|
||||
"am start -n com.netflix.ninja/.MainActivity "
|
||||
"-a android.intent.action.VIEW -c android.intent.category.DEFAULT "
|
||||
"-d https://www.netflix.com/watch/80100172 "
|
||||
"--es id 80100172 --ez resume true"
|
||||
)
|
||||
|
||||
#: What a real `am start` prints when the device ACCEPTED the intent.
|
||||
DEVICE_ANSWER = (
|
||||
"Starting: Intent { act=android.intent.action.VIEW "
|
||||
"cmp=com.netflix.ninja/.MainActivity }"
|
||||
)
|
||||
|
||||
|
||||
class FakePoller:
|
||||
"""The ADB surface the handler uses: one shell call, real values back."""
|
||||
|
||||
def __init__(self, output=DEVICE_ANSWER, error=None):
|
||||
self.output = output
|
||||
self.error = error
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def shell(self, command):
|
||||
self.calls.append(command)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.output
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="ha")
|
||||
async def ha_fixture(tmp_path):
|
||||
"""A real HomeAssistant with the real registries and the real service registry.
|
||||
|
||||
Same shape as the adb_command file (t14): no core bootstrap, but the two
|
||||
containers core's loader fills are constructed the very same way, so an entity
|
||||
target expansion sees the entities the real one would.
|
||||
"""
|
||||
hass = HomeAssistant(str(tmp_path))
|
||||
device_registry.async_setup(hass)
|
||||
await device_registry.async_load(hass)
|
||||
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
|
||||
hass.config_entries = ConfigEntries(hass, {})
|
||||
hass.config_entries.async_schedule_reload = lambda entry_id: None
|
||||
entry = ConfigEntry(
|
||||
version=1,
|
||||
minor_version=1,
|
||||
domain=DOMAIN,
|
||||
title="Shield",
|
||||
data={},
|
||||
options={},
|
||||
source=SOURCE_USER,
|
||||
unique_id=None,
|
||||
subentries_data=(),
|
||||
discovery_keys={},
|
||||
)
|
||||
object.__setattr__(entry, "_options", {})
|
||||
hass.config_entries._entries[entry.entry_id] = entry
|
||||
hass._shield_entry = entry
|
||||
return hass
|
||||
|
||||
|
||||
@pytest.fixture(name="integration")
|
||||
def integration_fixture():
|
||||
"""The integration package, imported by path under its real name."""
|
||||
return importlib.import_module("custom_components.nvidia_shield")
|
||||
|
||||
|
||||
@pytest.fixture(name="media_player_module")
|
||||
def media_player_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.media_player")
|
||||
|
||||
|
||||
@pytest.fixture(name="const")
|
||||
def const_fixture():
|
||||
return importlib.import_module("custom_components.nvidia_shield.const")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(name="shield")
|
||||
async def shield_fixture(ha, integration, media_player_module, const):
|
||||
"""Set the integration up with ONE Shield carrying ONE registered intent.
|
||||
|
||||
The manager is a SimpleNamespace with the ENTRY on it, because the handler
|
||||
reads the intents from the ENTRY's options — the same place the options flow
|
||||
writes them, which is what makes this a real end-to-end path.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
||||
manager = SimpleNamespace(adb_poller=poller, connected=True, entry=entry)
|
||||
media_player_module._INTENT_TARGETS[ENTITY_ID] = manager
|
||||
# the domain store a real entry setup fills, then the SAME call
|
||||
# async_setup_entry makes — so the dropdown is published exactly as in
|
||||
# production (and, when nothing is stored, comes out empty)
|
||||
ha.data.setdefault(DOMAIN, {})["shield_entry"] = manager
|
||||
integration._publish_send_intent_description(ha)
|
||||
try:
|
||||
yield poller
|
||||
finally:
|
||||
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
|
||||
ha.data.get(DOMAIN, {}).pop("shield_entry", None)
|
||||
|
||||
|
||||
def _description(descriptions):
|
||||
return descriptions[DOMAIN][SERVICE]
|
||||
|
||||
|
||||
class RecordingConnection:
|
||||
"""The websocket connection shape ``handle_get_services`` needs."""
|
||||
|
||||
def __init__(self):
|
||||
self.messages: list[dict] = []
|
||||
|
||||
def send_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def send_result(self, msg_id, payload):
|
||||
self.messages.append({"id": msg_id, "type": "result", "result": payload})
|
||||
|
||||
|
||||
async def test_the_registration_declares_an_optional_response(ha, integration, shield):
|
||||
"""A response-bearing action must say so — OPTIONAL keeps callers free.
|
||||
|
||||
REQUIRED would make every existing automation that does not ask for a response
|
||||
fail, so this is a compatibility gate, not a nicety.
|
||||
"""
|
||||
from homeassistant.core import SupportsResponse
|
||||
|
||||
service = ha.services.async_services()[DOMAIN][SERVICE]
|
||||
|
||||
assert service.supports_response is SupportsResponse.OPTIONAL
|
||||
|
||||
|
||||
async def test_the_intent_field_is_a_dropdown_of_the_registered_names(
|
||||
ha, integration, shield
|
||||
):
|
||||
"""THE dropdown: the frontend gets the registered intents as select options.
|
||||
|
||||
The options are published from the integration's own runtime description
|
||||
(``async_set_service_schema``), because ``async_register`` has no field
|
||||
metadata parameter — the same mechanism t14 proved for the target picker.
|
||||
"""
|
||||
description = _description(await async_get_all_descriptions(ha))
|
||||
|
||||
intent_field = description["fields"]["intent"]
|
||||
assert intent_field["required"] is True
|
||||
assert intent_field["selector"]["select"]["options"] == [NETFLIX_INTENT["name"]]
|
||||
assert intent_field["selector"]["select"]["custom_value"] is True
|
||||
|
||||
# the target picker, and the response flag the UI reads
|
||||
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
|
||||
assert description["response"] == {"optional": True}
|
||||
assert set(description["fields"]) == {"intent", "variables", "entity_id"}
|
||||
|
||||
# ...and the whole payload is JSON, which is what crosses the wire
|
||||
assert json.loads(json.dumps(description))["fields"]["intent"]["selector"]
|
||||
|
||||
|
||||
async def test_a_publish_with_nothing_stored_offers_no_options(ha, integration):
|
||||
"""The honest empty state: no registrations means an EMPTY dropdown.
|
||||
|
||||
The action still accepts a name (the selector keeps ``custom_value``), so the
|
||||
screen is not a dead end — it just does not invent options.
|
||||
"""
|
||||
await integration.async_setup(ha, {})
|
||||
ha.data.setdefault(DOMAIN, {}).clear()
|
||||
integration._publish_send_intent_description(ha)
|
||||
|
||||
description = _description(await async_get_all_descriptions(ha))
|
||||
|
||||
assert description["fields"]["intent"]["selector"]["select"]["options"] == []
|
||||
|
||||
|
||||
async def test_the_websocket_command_serves_the_same_dropdown(
|
||||
ha, integration, shield
|
||||
):
|
||||
"""The exact command the frontend calls, not the accessor behind it."""
|
||||
connection = RecordingConnection()
|
||||
ws_commands.handle_get_services(ha, connection, {"id": 11, "type": "get_services"})
|
||||
await ha.async_block_till_done()
|
||||
|
||||
assert len(connection.messages) == 1
|
||||
message = json.loads(connection.messages[0])
|
||||
assert message["id"] == 11
|
||||
served = message["result"][DOMAIN][SERVICE]
|
||||
assert served["fields"]["intent"]["selector"]["select"]["options"] == [
|
||||
NETFLIX_INTENT["name"]
|
||||
]
|
||||
assert served["response"] == {"optional": True}
|
||||
|
||||
|
||||
async def test_a_call_returns_the_device_s_own_output(ha, integration, shield):
|
||||
"""The whole point: the Shield's answer comes back to the caller."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response == {"intent_response": DEVICE_ANSWER}
|
||||
assert shield.calls == [EXPECTED_COMMAND]
|
||||
|
||||
|
||||
async def test_a_caller_that_asks_for_no_response_still_sends(ha, integration, shield):
|
||||
"""A template that ignores the answer must not change the behaviour."""
|
||||
response = await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=False,
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert shield.calls == [EXPECTED_COMMAND]
|
||||
|
||||
|
||||
async def test_the_variables_are_typed_onto_the_command(ha, integration, shield):
|
||||
"""A declared int/bool reaches the device as its own flag, not as text."""
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "80100172", "resume": "no"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert shield.calls == [
|
||||
EXPECTED_COMMAND.replace("--ez resume true", "--ez resume false")
|
||||
]
|
||||
|
||||
|
||||
async def test_a_required_field_left_out_names_the_field(ha, integration, shield):
|
||||
"""The action refuses BEFORE it sends: nothing travels half-specified."""
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": NETFLIX_INTENT["name"], "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "missing_intent_field"
|
||||
assert caught.value.translation_placeholders["field"] == "id"
|
||||
assert "id" in str(caught.value)
|
||||
assert shield.calls == [], "nothing was sent"
|
||||
|
||||
|
||||
async def test_an_unknown_intent_lists_the_registered_ones(ha, integration, shield):
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": "TiviMate: channel", "entity_id": ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "unknown_intent"
|
||||
assert NETFLIX_INTENT["name"] in str(caught.value)
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_a_refused_intent_carries_the_device_s_message(ha, integration, shield):
|
||||
"""The uninstalled-package case: the device's words, and an ERROR, not "ok"."""
|
||||
shield.output = (
|
||||
"Starting: Intent { act=android.intent.action.VIEW }\n"
|
||||
"Error: Activity class {com.netflix.ninja/.MainActivity} does not exist."
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert caught.value.translation_key == "intent_failed"
|
||||
assert "does not exist" in str(caught.value)
|
||||
assert len(shield.calls) == 1, "it was attempted exactly once, not retried"
|
||||
|
||||
|
||||
async def test_adb_down_says_the_protocol_cannot_carry_an_intent(
|
||||
ha, integration, media_player_module, const
|
||||
):
|
||||
"""The honest answer when the transport is the problem — not an empty reply."""
|
||||
await integration.async_setup(ha, {})
|
||||
poller = FakePoller()
|
||||
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
|
||||
media_player_module._INTENT_TARGETS[ENTITY_ID] = SimpleNamespace(
|
||||
adb_poller=poller, connected=False, entry=entry
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": ENTITY_ID,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
finally:
|
||||
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
|
||||
|
||||
assert caught.value.translation_key == "intents_need_adb"
|
||||
assert "protocol" in str(caught.value)
|
||||
assert poller.calls == []
|
||||
|
||||
|
||||
async def test_the_target_is_required_and_never_guessed(ha, integration, shield):
|
||||
"""No target, and an entity that does not participate — two errors, named."""
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{"intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert caught.value.translation_key == "no_target"
|
||||
assert SERVICE in str(caught.value), "the error names the action to fix"
|
||||
|
||||
with pytest.raises(ServiceValidationError) as caught:
|
||||
await ha.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE,
|
||||
{
|
||||
"intent": NETFLIX_INTENT["name"],
|
||||
"variables": {"id": "1"},
|
||||
"entity_id": "media_player.other",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert caught.value.translation_key == "unsupported_target"
|
||||
assert shield.calls == []
|
||||
|
||||
|
||||
async def test_the_strings_cover_every_error_this_file_provokes():
|
||||
"""The translation keys the action raises exist in BOTH strings files.
|
||||
|
||||
A key without copy reaches the user as the raw key, so this is checked where
|
||||
the real exception is raised rather than only in the unit suite.
|
||||
"""
|
||||
shield_dir = _ROOT / "custom_components" / "nvidia_shield"
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
data = json.loads((shield_dir / name).read_text(encoding="utf-8"))
|
||||
for key in (
|
||||
"unknown_intent",
|
||||
"missing_intent_field",
|
||||
"unknown_intent_variable",
|
||||
"invalid_intent_field",
|
||||
"intent_failed",
|
||||
"intents_need_adb",
|
||||
"no_target",
|
||||
"unsupported_target",
|
||||
"ambiguous_target",
|
||||
):
|
||||
assert key in data["exceptions"], (name, key)
|
||||
for field in ("intent", "variables", "entity_id"):
|
||||
assert field in data["services"][SERVICE]["fields"], (name, field)
|
||||
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