Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,158 @@
|
|||||||
|
# Publish a date-based release every time master moves.
|
||||||
|
#
|
||||||
|
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
|
||||||
|
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
|
||||||
|
# manifest to force a specific release, e.g. after a meaningful change); otherwise
|
||||||
|
# the version is today's date (UTC), so a plain push to master publishes the
|
||||||
|
# day's release. Repeated pushes on the same day are deduped by tag existence.
|
||||||
|
#
|
||||||
|
# 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 tag and creates the release.
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
ZIP_NAME: nvidia_shield.zip
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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 release version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
|
||||||
|
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||||
|
# An unreleased manifest version wins over today's date.
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
|
||||||
|
VERSION="${DATE_VERSION}"
|
||||||
|
else
|
||||||
|
VERSION="${MANIFEST_VERSION}"
|
||||||
|
fi
|
||||||
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||||
|
echo "v${VERSION} is already tagged — nothing to release"
|
||||||
|
echo "exists=yes" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "releasing v${VERSION}"
|
||||||
|
echo "exists=no" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create tag
|
||||||
|
if: steps.version.outputs.exists == 'no'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@notexpectedyet.com"
|
||||||
|
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||||
|
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
|
||||||
|
|
||||||
|
- name: Build the release zip
|
||||||
|
if: steps.version.outputs.exists == 'no'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd custom_components
|
||||||
|
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||||
|
|
||||||
|
- name: Publish the release
|
||||||
|
if: steps.version.outputs.exists == 'no'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
|
SERVER: ${{ github.server_url }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Notes: subject lines of the commits in the released range.
|
||||||
|
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||||
|
if [ -n "$PREV" ]; then
|
||||||
|
RANGE="${PREV}..${TAG}"
|
||||||
|
else
|
||||||
|
RANGE="${TAG}"
|
||||||
|
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,30 @@
|
|||||||
|
# Quality gate: lint + unit tests.
|
||||||
|
#
|
||||||
|
# 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).
|
||||||
|
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install test tooling
|
||||||
|
run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: ruff check custom_components tests
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: python3 -m pytest tests -q
|
||||||
@@ -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,75 +1,171 @@
|
|||||||
# NVIDIA Shield
|
# NVIDIA Shield
|
||||||
|
|
||||||
[![hacs][hacs-badge]][hacs-url] [](LICENSE)
|
Home Assistant integration for the NVIDIA Shield TV — ADB media session tracking for
|
||||||
|
now-playing state from any app, plus Google remote-protocol commands, Jellyfin rich
|
||||||
|
metadata, and power monitoring.
|
||||||
|
|
||||||
Enhanced NVIDIA Shield TV integration for Home Assistant. ADB-based media session tracking, Jellyfin rich metadata, power monitoring, and receiver coordination.
|
This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/ltomes/ha-nvidia-shield)
|
||||||
|
(early 0.1.x), rebuilt around a different architecture: one integration, one device,
|
||||||
|
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
||||||
|
integrations.
|
||||||
|
|
||||||
> **Status:** Early access (0.1.x). Tested on the author's own setup; expect rough edges and occasional breaking changes until 1.0. Please file issues on GitHub if anything is broken.
|
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
|
||||||
|
> Expect rough edges until the MVP phases land. The build-out plan is in
|
||||||
|
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||||||
|
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||||||
|
|
||||||
```mermaid
|
## What we're building (the fork's goals)
|
||||||
graph TD
|
|
||||||
subgraph "NVIDIA Shield Integration"
|
|
||||||
ADB["ADB Media Session"] -->|now playing| Entity["media_player.nvidia_shield"]
|
|
||||||
JF["Jellyfin"] -->|rich metadata| Entity
|
|
||||||
ATV["Android TV Remote"] -->|power / commands| Entity
|
|
||||||
PWR["Smart Plug"] -->|power state| Entity
|
|
||||||
end
|
|
||||||
|
|
||||||
Entity -->|"media_dag_*"| MZ["Media Zone"]
|
The fork exists to make a single integration own the Shield end-to-end:
|
||||||
|
|
||||||
style Entity fill:#76b900,color:#fff
|
1. **Event-driven ADB media session tracking as the primary playback state source** —
|
||||||
style MZ fill:#1a73e8,color:#fff
|
`dumpsys media_session` over a persistent connection, not screen capture, not
|
||||||
style ADB fill:#444,color:#fff
|
10-second polling.
|
||||||
style JF fill:#00a4dc,color:#fff
|
2. **Fallback state for apps without media sessions** — e.g. TiviMate publishes
|
||||||
style ATV fill:#444,color:#fff
|
none at all; foreground-app detection (`mResumedActivity`) + last-known-state
|
||||||
style PWR fill:#444,color:#fff
|
+ the "audio playback" hint from the same session dump close that gap.
|
||||||
```
|
3. **All-in-one device** — absorb the Google remote protocol (`androidtvremote2`):
|
||||||
|
a `remote` entity alongside the media player, `play_media` with
|
||||||
|
`media_content_type: channel` for TiviMate channel-by-number, absolute volume,
|
||||||
|
focused-app push events. ADB stays the playback-state source; ADB pairing stays intact.
|
||||||
|
4. **Retire the surrounding glue** — the `mediasession2mqtt` bridge, universal
|
||||||
|
media player templates, and external remote entity reading all become native
|
||||||
|
integration behavior.
|
||||||
|
|
||||||
## Features
|
Phases 0–4 with files, risks, and definitions of done live in
|
||||||
|
[`docs/MVP_PLAN.md`](docs/MVP_PLAN.md).
|
||||||
|
|
||||||
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin
|
## Current features (inherited from upstream 0.1.x)
|
||||||
- **Jellyfin Rich Metadata** -- Title, series, season/episode, duration, position, stream details (resolution, codec, HDR, audio format)
|
|
||||||
- **Power Monitoring** -- Smart plug for reliable on/off detection with auto-discovered sibling sensors (watts, voltage, current)
|
- **ADB Media Session** — now-playing from any app that publishes a session
|
||||||
- **Receiver Coordination** -- Links to a receiver entity and input name
|
(Spotify, Netflix, Plex, Wholphin, …)
|
||||||
- **Intent Tracking** -- Commands reflect in HA state immediately, self-heal against actual device state
|
- **Jellyfin rich metadata** — title, series/episode, position, stream details
|
||||||
- **Privacy Mode** -- Show only app name without media details for non-Jellyfin apps
|
(resolution, codec, HDR, audio format) via the HA Jellyfin integration's cache
|
||||||
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity
|
- **Power monitoring** — smart plug for reliable on/off with auto-discovered
|
||||||
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration
|
sibling sensors (watts, voltage, current)
|
||||||
|
- **Intent tracking** — commands reflect in HA state immediately, self-heal
|
||||||
|
against actual device state
|
||||||
|
- **Privacy mode** — show only app name without media details for non-Jellyfin apps
|
||||||
|
- **Media DAG attributes** — `media_dag_*` attributes for media-zone style orchestration
|
||||||
|
|
||||||
|
Changes inherited from upstream still apply in 0.1.x code: polling is timer-based,
|
||||||
|
the ADB connection lives inside the media player entity, and fallback app state
|
||||||
|
arrives in Phase 3.
|
||||||
|
|
||||||
|
## State ladder and fallback semantics (Phase 3)
|
||||||
|
|
||||||
|
The media player state is decided top-down; each tier serves only when the
|
||||||
|
ones above it are silent. Both transports live in the entry's
|
||||||
|
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
|
||||||
|
remote protocol is the fast push/command path:
|
||||||
|
|
||||||
|
1. **Plug off** — the power switch says `off` → `off`.
|
||||||
|
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
|
||||||
|
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
|
||||||
|
4. **Intent** — a just-sent command's target state, self-healing against
|
||||||
|
reality within 30 s (retry goes over the transport that served it).
|
||||||
|
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
|
||||||
|
6. **ADB media session** — `playing` / `paused` for apps that publish a
|
||||||
|
session (foreground app preferred).
|
||||||
|
7. **Foreground fallback** — an app is open (`current_app` protocol push,
|
||||||
|
then the ADB `mResumedActivity` parse) but publishes no media session →
|
||||||
|
`on`. Its identity comes from the TTL-capped last-known-state cache, the
|
||||||
|
last channel entered, or the zero-cost "audio playback" hint
|
||||||
|
(`media recently active`) — never resurrecting `playing` past the 60 s
|
||||||
|
state TTL.
|
||||||
|
8. **On** — powered, awake, nothing more specific.
|
||||||
|
|
||||||
|
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
|
||||||
|
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
|
||||||
|
the audio hint are what keep it visible.
|
||||||
|
|
||||||
|
**Standalone setup** accepts any one of the three state sources — the Android
|
||||||
|
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
|
||||||
|
media-session mode (ADB enabled + host). The `no_state_source` form error
|
||||||
|
fires only when none of the three is present; ADB-only setups are valid.
|
||||||
|
|
||||||
|
### Channel entry options
|
||||||
|
|
||||||
|
The options flow exposes the §7 channel behavior the integration replicates:
|
||||||
|
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
|
||||||
|
**ENTER press** after the digits (off by default — TiviMate's validated
|
||||||
|
behavior). Both are consumed by `play_media` with
|
||||||
|
`media_content_type: channel`.
|
||||||
|
|
||||||
|
### Raw `adb_command` service (opt-in)
|
||||||
|
|
||||||
|
When enabled in the integration's options (`adb_command_enabled`, **default
|
||||||
|
off**), the integration registers the `nvidia_shield.adb_command` service:
|
||||||
|
send a raw ADB shell command to the entry's persistent connection
|
||||||
|
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
|
||||||
|
entity is optional when exactly one entry provides the service). This is the
|
||||||
|
same security posture as the Home Assistant core `androidtv` integration: a
|
||||||
|
documented, opt-in escape hatch for debugging and automation, registered at
|
||||||
|
setup and unregistered when the last participating entity unloads. It
|
||||||
|
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||||||
|
|
||||||
|
### Migration from mediasession2mqtt + universal-player glue
|
||||||
|
|
||||||
|
The `mediasession2mqtt` bridge sensors and the universal media player YAML
|
||||||
|
templates the fork replaces are retired by the ladder above: the media player
|
||||||
|
entity already carries the DAG attributes (`media_dag_running_apps` with
|
||||||
|
foreground/last-known enrichment, `media_dag_now_playing` with its source
|
||||||
|
tag) that media-zone orchestration consumed from those sensors. See the
|
||||||
|
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
|
||||||
|
retirement mapping and the universal-player glue it replaces.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
||||||
|
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
|
||||||
|
run the release workflow (`.github/workflows/release.yml`):
|
||||||
|
- A plain push publishes the current day's release (same-day pushes are deduped).
|
||||||
|
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
|
||||||
|
specific version's release before the date fallback kicks in.
|
||||||
|
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
|
||||||
|
installs. (No automatic mirroring — see the HACS note below.)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### HACS (recommended)
|
### HACS
|
||||||
|
|
||||||
|
HACS only works with public GitHub repositories, so the Gitea repo cannot be
|
||||||
|
added to HACS directly. If you set up a public GitHub mirror of the releases,
|
||||||
|
add it as a custom repository:
|
||||||
|
|
||||||
1. In HACS, open the overflow menu (⋮) → **Custom repositories**.
|
1. In HACS, open the overflow menu (⋮) → **Custom repositories**.
|
||||||
2. Paste `https://github.com/ltomes/ha-nvidia-shield`, pick type **Integration**, click **Add**.
|
2. Paste `https://github.com/notexpectedyet/ha-nvidia-shield`, pick type
|
||||||
|
**Integration**, click **Add**.
|
||||||
3. Find **NVIDIA Shield** in the HACS list and click **Download**.
|
3. Find **NVIDIA Shield** in the HACS list and click **Download**.
|
||||||
4. **Restart Home Assistant.**
|
4. **Restart Home Assistant.**
|
||||||
5. Go to **Settings → Devices & Services → Add Integration**, search for **NVIDIA Shield**, and set up the integration (see [Configuration](#configuration)).
|
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/` directory, restart, then add the integration from **Settings → Devices & Services**.
|
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/`
|
||||||
|
directory, restart, then add the integration from **Settings → Devices & Services**.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||||
2. Select the Android TV Remote entity for your Shield
|
2. Select the Android TV Remote entity for your Shield
|
||||||
3. Enable ADB if you want now-playing from all apps
|
3. Enable ADB if you want now-playing from all apps
|
||||||
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield
|
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
||||||
5. Optionally configure Jellyfin, receiver, power switch
|
5. Optionally configure Jellyfin, receiver, power switch
|
||||||
|
|
||||||
### ADB Pairing
|
### ADB Pairing
|
||||||
|
|
||||||
ADB is paired during setup via a dedicated config flow step. If you need to re-pair later (e.g., after factory reset), use the **"Pair ADB"** button on the device page in HA.
|
ADB is paired during setup via a dedicated config flow step. If you need to
|
||||||
|
re-pair later (e.g. after factory reset), use the **"Pair ADB"** button on the
|
||||||
|
device page in HA.
|
||||||
|
|
||||||
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
|
||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
- **Jellyfin Media Player** -- Auto-detected or manually selected
|
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||||
- **Receiver + Input** -- For Media Zone coordination
|
- **Receiver + Input** — for media-zone coordination
|
||||||
- **Power Switch** -- Smart plug entity
|
- **Power Switch** — smart plug entity
|
||||||
- **ADB Host/Port** -- Override auto-detection if needed
|
- **ADB Host/Port** — override auto-detection if needed
|
||||||
|
|
||||||
## Entities
|
## Entities
|
||||||
|
|
||||||
@@ -82,20 +178,12 @@ Requirements: Settings > Developer options > Network debugging enabled on the Sh
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Shared ADB helpers live in [ha-adb-common](https://github.com/ltomes/ha-adb-common) and are also used by [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv). For HACS installs, a copy is **vendored** at `custom_components/nvidia_shield/ha_adb_common/` so end users need nothing extra. We plan to publish `ha-adb-common` to PyPI so it can be declared as a normal `manifest.json` requirement; until then, the vendored copy is the source consumed at runtime.
|
- **Repository:** [Gitea — git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield), branch `master`
|
||||||
|
- **CI/CD:** Gitea Actions; every push to `master` releases (see
|
||||||
To hack on the shared code locally, clone [ha-adb-common](https://github.com/ltomes/ha-adb-common) as a sibling directory and run `uv sync --extra dev` — the `[tool.uv.sources]` section in `pyproject.toml` wires it up editable.
|
`.gitea/workflows/release.yml`). Auth uses the job-provided `GITEA_TOKEN` —
|
||||||
|
no extra secrets required.
|
||||||
## Part of the Media Zone Ecosystem
|
- **Shared ADB helpers** are vendored at `custom_components/nvidia_shield/ha_adb_common/`
|
||||||
|
(trimmed in this fork); dev extras in `pyproject.toml` (`uv sync --extra dev`).
|
||||||
This integration works standalone, but pairs with [ha-media-zone](https://github.com/ltomes/ha-media-zone) to create a unified zone controller:
|
|
||||||
|
|
||||||
- [ha-media-zone](https://github.com/ltomes/ha-media-zone) -- Orchestrates receiver + display + sources into one entity
|
|
||||||
- [ha-emotiva-mc1](https://github.com/ltomes/ha-emotiva-mc1) -- RS232 control for Emotiva MC1 receiver
|
|
||||||
- [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv) -- Hisense Google TV control via ADB + AirPlay
|
|
||||||
|
|
||||||
[hacs-badge]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg
|
|
||||||
[hacs-url]: https://github.com/hacs/integration
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,72 @@
|
|||||||
"""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 logging
|
||||||
|
import os
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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 homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .adb_session import AdbMediaSessionPoller
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
|
from .const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON]
|
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@@ -18,6 +74,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = entry
|
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)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||||
@@ -25,11 +99,66 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||||
|
|
||||||
|
Falls back to the androidtv-remote host discovery (the ADB host fallback)
|
||||||
|
until the user pairs, after which the explicit protocol host persists.
|
||||||
|
"""
|
||||||
|
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
||||||
|
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
|
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 v1 entries to v2 (adds the remote-protocol fields).
|
||||||
|
|
||||||
|
ADB options carry over untouched; the ADB-host discovery fallback keeps
|
||||||
|
serving as the protocol host until pairing populates it. Downgrades
|
||||||
|
(version > 2) are rejected so HA offers a rollback.
|
||||||
|
"""
|
||||||
|
if config_entry.version > 2:
|
||||||
|
return False
|
||||||
|
if config_entry.version < 2:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
options.setdefault(CONF_PROTOCOL_ENABLED, False)
|
||||||
|
options.setdefault(CONF_PROTOCOL_HOST, "")
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, version=2
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Migrated %s config entry to version 2", config_entry.title
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
if unload_ok:
|
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)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|||||||
@@ -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
|
Since Phase 1 this module is a thin adapter on top of ha_adb_common:
|
||||||
`poll()` method that calls wake check + media session in sequence.
|
|
||||||
|
- ``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.
|
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
|
from .ha_adb_common import (
|
||||||
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
|
AdbClientBase,
|
||||||
|
MediaSessionInfo, # noqa: F401 (re-export)
|
||||||
|
parse_foreground,
|
||||||
|
)
|
||||||
|
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
|
|||||||
prefer its media session over stale sessions from other apps.
|
prefer its media session over stale sessions from other apps.
|
||||||
"""
|
"""
|
||||||
return await self.poll_media_sessions(foreground_package)
|
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,7 +5,6 @@ import logging
|
|||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
@@ -18,6 +17,7 @@ from .const import (
|
|||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .helpers import discover_adb_host
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,29 +52,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
model="Shield TV",
|
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:
|
async def async_press(self) -> None:
|
||||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||||
host = self._resolve_host()
|
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||||
|
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||||
|
|
||||||
if not host:
|
if not host:
|
||||||
@@ -85,7 +67,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
|||||||
ensure_adb_key, self.hass.config.config_dir
|
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)
|
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||||
if await poller.connect():
|
if await poller.connect():
|
||||||
|
|||||||
@@ -1,29 +1,42 @@
|
|||||||
"""Config flow for Nvidia Shield integration."""
|
"""Config flow for Nvidia Shield integration."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import entity_registry as er, selector
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
from .adb_session import ensure_adb_key
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||||
|
CHANNEL_DIGIT_DELAY,
|
||||||
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_ENABLED,
|
CONF_ADB_ENABLED,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_HOST,
|
||||||
CONF_ADB_PORT,
|
CONF_ADB_PORT,
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_APP_ICON_MAP,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
|
CONF_LOGCAT_EVENTS,
|
||||||
|
CONF_PAIRING_CODE,
|
||||||
CONF_POWER_ENTITY,
|
CONF_POWER_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
CONF_RECEIVER_INPUT,
|
CONF_RECEIVER_INPUT,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_ADB_PORT,
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
from .helpers import discover_adb_host, protocol_cert_paths
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,6 +82,81 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _app_map_to_text(app_map: dict, icon_map: dict | None = None) -> str:
|
||||||
|
"""Serialize the apps map to one ``package=name[|icon]`` per line."""
|
||||||
|
icon_map = icon_map or {}
|
||||||
|
lines = []
|
||||||
|
for pkg, name in app_map.items():
|
||||||
|
icon = icon_map.get(pkg)
|
||||||
|
lines.append(f"{pkg}={name}|{icon}" if icon else f"{pkg}={name}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _app_map_from_text(
|
||||||
|
text: str, icons: dict[str, str] | None = None
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
"""Parse one ``package=name[|icon]`` per line; None on malformed input.
|
||||||
|
|
||||||
|
The optional second column (a picture URL or entity id) feeds
|
||||||
|
entity_picture through CONF_APP_ICON_MAP; single-column maps stay
|
||||||
|
valid. Malformed icon text (spaces) invalidates the row the same way
|
||||||
|
a malformed name does.
|
||||||
|
"""
|
||||||
|
icons = icons if icons is not None else {}
|
||||||
|
app_map: dict[str, str] = {}
|
||||||
|
for line in text.split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
return None
|
||||||
|
package, value = line.split("=", 1)
|
||||||
|
parts = value.strip().split("|", 1)
|
||||||
|
name = parts[0].strip()
|
||||||
|
icon = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
package = package.strip()
|
||||||
|
name = name.strip()
|
||||||
|
if not package or not name or " " in package:
|
||||||
|
return None
|
||||||
|
if icon:
|
||||||
|
if " " in icon:
|
||||||
|
return None
|
||||||
|
icons[package] = icon
|
||||||
|
app_map[package] = name
|
||||||
|
return app_map
|
||||||
|
|
||||||
|
|
||||||
|
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||||
|
"""User-step schema — the androidtv entity is optional (standalone).
|
||||||
|
|
||||||
|
``defaults`` pre-fills name/hosts from a zeroconf discovery event.
|
||||||
|
"""
|
||||||
|
defaults = defaults or {}
|
||||||
|
schema_dict = {
|
||||||
|
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
|
||||||
|
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
||||||
|
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||||
|
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# If there are any receivers with DAG input maps, show a text field
|
||||||
|
# for receiver input (will be a dropdown in options once receiver is set)
|
||||||
|
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||||
|
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=False)] = bool
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||||
|
if "host" in defaults:
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_HOST, default=defaults["host"])] = str
|
||||||
|
schema_dict[vol.Optional(CONF_PROTOCOL_HOST, default=defaults["host"])] = str
|
||||||
|
return schema_dict
|
||||||
|
|
||||||
|
|
||||||
def _build_selectors():
|
def _build_selectors():
|
||||||
"""Build reusable selector instances."""
|
"""Build reusable selector instances."""
|
||||||
return {
|
return {
|
||||||
@@ -90,26 +178,56 @@ def _build_selectors():
|
|||||||
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Nvidia Shield."""
|
"""Handle a config flow for Nvidia Shield."""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 2
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._user_data = {}
|
self._user_data = {}
|
||||||
|
self._protocol_client = None
|
||||||
|
self._reauth_entry = None
|
||||||
|
# Zeroconf discovery state (P4-1)
|
||||||
|
self._discovered_host: str | None = None
|
||||||
|
self._discovered_name: str | None = None
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input=None):
|
||||||
"""Step 1: Basic setup."""
|
"""Step 1: Basic setup.
|
||||||
|
|
||||||
|
Standalone setup (Phase 3): any one of the three state sources is
|
||||||
|
enough — the Android TV Remote entity, the Nvidia Remote Protocol
|
||||||
|
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The
|
||||||
|
``no_state_source`` error fires only when none of them is present.
|
||||||
|
"""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY]
|
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
|
||||||
await self.async_set_unique_id(androidtv_entity)
|
has_source = (
|
||||||
self._abort_if_unique_id_configured()
|
bool(androidtv_entity)
|
||||||
|
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
|
||||||
|
or bool(user_input.get(CONF_ADB_ENABLED))
|
||||||
|
)
|
||||||
|
if not has_source:
|
||||||
|
errors["base"] = "no_state_source"
|
||||||
|
sel = _build_selectors()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(_user_schema_dict(sel)),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
if androidtv_entity:
|
||||||
|
await self.async_set_unique_id(androidtv_entity)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
# Auto-detect Jellyfin if not manually specified
|
# Auto-detect Jellyfin if not manually specified
|
||||||
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||||
if not jellyfin:
|
if not jellyfin:
|
||||||
device_name = user_input.get("name", DEFAULT_NAME)
|
device_name = user_input.get("name", DEFAULT_NAME)
|
||||||
atv_state = self.hass.states.get(androidtv_entity)
|
atv_state = (
|
||||||
|
self.hass.states.get(androidtv_entity)
|
||||||
|
if androidtv_entity
|
||||||
|
else None
|
||||||
|
)
|
||||||
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
||||||
|
|
||||||
for name in [device_name, atv_name]:
|
for name in [device_name, atv_name]:
|
||||||
@@ -123,7 +241,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
self._user_data = {
|
self._user_data = {
|
||||||
"name": user_input.get("name", DEFAULT_NAME),
|
"name": user_input.get("name", DEFAULT_NAME),
|
||||||
CONF_ANDROIDTV_ENTITY: androidtv_entity,
|
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
||||||
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||||
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||||
@@ -132,34 +250,29 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
||||||
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False),
|
||||||
|
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# If ADB is enabled, go to pairing step
|
# If ADB is enabled, go to pairing step
|
||||||
if self._user_data[CONF_ADB_ENABLED]:
|
if self._user_data[CONF_ADB_ENABLED]:
|
||||||
return await self.async_step_adb_pair()
|
return await self.async_step_adb_pair()
|
||||||
|
|
||||||
|
if self._user_data[CONF_PROTOCOL_ENABLED]:
|
||||||
|
return await self.async_step_protocol_pair()
|
||||||
|
|
||||||
return self._create_entry()
|
return self._create_entry()
|
||||||
|
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
|
|
||||||
schema_dict = {
|
defaults = {}
|
||||||
vol.Required("name", default=DEFAULT_NAME): str,
|
if self._discovered_host:
|
||||||
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
defaults = {
|
||||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
"name": self._discovered_name or DEFAULT_NAME,
|
||||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
"host": self._discovered_host,
|
||||||
}
|
}
|
||||||
|
schema_dict = _user_schema_dict(sel, defaults)
|
||||||
# If there are any receivers with DAG input maps, show a text field
|
|
||||||
# for receiver input (will be a dropdown in options once receiver is set)
|
|
||||||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
|
||||||
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
|
||||||
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
|
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
@@ -167,12 +280,68 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_step_zeroconf(self, discovery_info):
|
||||||
|
"""Zeroconf discovery of the Android TV remote service (P4-1).
|
||||||
|
|
||||||
|
Core androidtv_remote pattern, simplified: the discovery address
|
||||||
|
becomes the pre-filled protocol/ADB host on the user step; a known
|
||||||
|
MAC (from the service properties) aborts duplicate setup and
|
||||||
|
updates the existing entry's hosts on an IP change.
|
||||||
|
"""
|
||||||
|
host = str(
|
||||||
|
getattr(discovery_info, "ip_address", None)
|
||||||
|
or getattr(discovery_info, "host", None)
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not host:
|
||||||
|
return self.async_abort(reason="zeroconf_no_host")
|
||||||
|
|
||||||
|
self._discovered_host = host
|
||||||
|
props = getattr(discovery_info, "properties", None) or {}
|
||||||
|
self._discovered_name = props.get("name")
|
||||||
|
|
||||||
|
mac = props.get("mac") or props.get("unique_id")
|
||||||
|
if mac:
|
||||||
|
await self.async_set_unique_id(mac)
|
||||||
|
for entry in self._async_current_entries():
|
||||||
|
if getattr(entry, "unique_id", None) != mac:
|
||||||
|
continue
|
||||||
|
# Known device — per-host updates (t21 F2): each host is
|
||||||
|
# tracked independently against the discovery address.
|
||||||
|
# - a blank (auto-detect) host self-heals from the address;
|
||||||
|
# - a stale protocol host is the IP change and is updated;
|
||||||
|
# - a deliberately-different ADB host (an explicit override
|
||||||
|
# that differs from both the address and the stored
|
||||||
|
# protocol host) is never clobbered.
|
||||||
|
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
|
||||||
|
adb_host = entry.options.get(CONF_ADB_HOST, "")
|
||||||
|
options = dict(entry.options)
|
||||||
|
if protocol_host != host:
|
||||||
|
options[CONF_PROTOCOL_HOST] = host
|
||||||
|
if not adb_host or adb_host == protocol_host:
|
||||||
|
# blank (self-heal) or auto-synced alongside the protocol
|
||||||
|
# host — safe to follow the device address on discovery
|
||||||
|
options[CONF_ADB_HOST] = host
|
||||||
|
if options != entry.options:
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
entry, options=options
|
||||||
|
)
|
||||||
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
# First-time setup: pre-filled user step
|
||||||
|
return await self.async_step_user()
|
||||||
|
|
||||||
def _create_entry(self):
|
def _create_entry(self):
|
||||||
"""Create the config entry from stored user data."""
|
"""Create the config entry from stored user data."""
|
||||||
d = self._user_data
|
d = self._user_data
|
||||||
|
entry_data = {}
|
||||||
|
if d.get(CONF_ANDROIDTV_ENTITY):
|
||||||
|
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=d["name"],
|
title=d["name"],
|
||||||
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]},
|
data=entry_data,
|
||||||
options={
|
options={
|
||||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||||
@@ -182,28 +351,131 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
||||||
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
||||||
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||||
|
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False),
|
||||||
|
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _async_after_pairing(self):
|
||||||
|
"""Route after the pairing steps: protocol next, else create the entry."""
|
||||||
|
if self._user_data.get(CONF_PROTOCOL_ENABLED):
|
||||||
|
return await self.async_step_protocol_pair()
|
||||||
|
return self._create_entry()
|
||||||
|
|
||||||
|
def _protocol_host(self) -> str | None:
|
||||||
|
"""Protocol host: explicit override, else the ADB host fallback."""
|
||||||
|
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
|
||||||
|
|
||||||
|
async def _start_protocol_pairing(self):
|
||||||
|
"""Build a throwaway protocol client and make the Shield show its PIN.
|
||||||
|
|
||||||
|
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
||||||
|
helpers.py pattern) and are shared between pairing and runtime.
|
||||||
|
"""
|
||||||
|
host = self._protocol_host()
|
||||||
|
if not host:
|
||||||
|
return None
|
||||||
|
certfile, keyfile = await self.hass.async_add_executor_job(
|
||||||
|
protocol_cert_paths, self.hass
|
||||||
|
)
|
||||||
|
client = ShieldConnectionManager.build_protocol_client(
|
||||||
|
host,
|
||||||
|
client_name=self._user_data.get("name", DEFAULT_NAME) or DEFAULT_NAME,
|
||||||
|
certfile=certfile,
|
||||||
|
keyfile=keyfile,
|
||||||
|
)
|
||||||
|
await client.async_generate_cert_if_missing()
|
||||||
|
await client.async_start_pairing()
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def async_step_protocol_pair(self, user_input=None):
|
||||||
|
"""Pair with the remote protocol — the PIN is displayed on the Shield."""
|
||||||
|
errors = {}
|
||||||
|
if user_input is None:
|
||||||
|
# First visit: proactively start pairing so the PIN shows on the
|
||||||
|
# Shield while the form is open.
|
||||||
|
client = await self._start_protocol_pairing()
|
||||||
|
if client is None:
|
||||||
|
errors["base"] = "protocol_no_host"
|
||||||
|
else:
|
||||||
|
self._protocol_client = client
|
||||||
|
else:
|
||||||
|
client = self._protocol_client
|
||||||
|
code = user_input.get(CONF_PAIRING_CODE, "")
|
||||||
|
if client is None or not code:
|
||||||
|
errors["base"] = "protocol_invalid_code"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await client.async_finish_pairing(code)
|
||||||
|
except Exception:
|
||||||
|
errors["base"] = "protocol_pair_failed"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
_name, mac = await client.async_get_name_and_mac()
|
||||||
|
except Exception:
|
||||||
|
mac = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
client.disconnect()
|
||||||
|
self._protocol_client = None
|
||||||
|
if mac:
|
||||||
|
await self.async_set_unique_id(mac)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
if self._reauth_entry is not None:
|
||||||
|
return self._finish_reauth()
|
||||||
|
return self._create_entry()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="protocol_pair",
|
||||||
|
data_schema=vol.Schema({
|
||||||
|
vol.Required(CONF_PAIRING_CODE): str,
|
||||||
|
}),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth(self, entry_data):
|
||||||
|
"""Device hard reset invalidated our certificates (review trigger)."""
|
||||||
|
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||||
|
self.context.get("entry_id")
|
||||||
|
)
|
||||||
|
if self._reauth_entry is None:
|
||||||
|
return self.async_abort(reason="reauth_entry_missing")
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_reauth_confirm(self, user_input=None):
|
||||||
|
"""Re-pair after a device hard reset, then reload the entry."""
|
||||||
|
if user_input is not None:
|
||||||
|
entry = self._reauth_entry
|
||||||
|
self._user_data = {
|
||||||
|
"name": entry.title,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||||||
|
}
|
||||||
|
return await self.async_step_protocol_pair() # first visit re-pairs
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reauth_confirm", data_schema=vol.Schema({})
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finish_reauth(self):
|
||||||
|
"""Reauth pairing done: update options, schedule the reload, abort."""
|
||||||
|
entry = self._reauth_entry
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
options={
|
||||||
|
**entry.options,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||||
|
self._reauth_entry = None
|
||||||
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
||||||
def _resolve_adb_host(self) -> str | None:
|
def _resolve_adb_host(self) -> str | None:
|
||||||
"""Resolve ADB host from explicit config or the Android TV entity."""
|
"""Resolve ADB host from explicit config or the Android TV entity."""
|
||||||
host = self._user_data.get(CONF_ADB_HOST)
|
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
||||||
if host:
|
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
return host
|
)
|
||||||
|
|
||||||
atv_entity = self._user_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_step_adb_pair(self, user_input=None):
|
async def async_step_adb_pair(self, user_input=None):
|
||||||
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
||||||
@@ -220,12 +492,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
ensure_adb_key, self.hass.config.config_dir
|
ensure_adb_key, self.hass.config.config_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
poller = AdbMediaSessionPoller(host, port, key_path)
|
connected = await ShieldConnectionManager.test_connect(
|
||||||
connected = await poller.connect()
|
host, port, key_path
|
||||||
await poller.disconnect()
|
)
|
||||||
|
|
||||||
if connected:
|
if connected:
|
||||||
return self._create_entry()
|
return await self._async_after_pairing()
|
||||||
else:
|
else:
|
||||||
errors["base"] = "adb_failed"
|
errors["base"] = "adb_failed"
|
||||||
|
|
||||||
@@ -250,13 +521,53 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
async def async_step_init(self, user_input=None):
|
||||||
"""Single-page options."""
|
"""Single-page options."""
|
||||||
|
errors = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._options.update(user_input)
|
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
||||||
return self.async_create_entry(title="", data=self._options)
|
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
|
||||||
|
if delay is not None:
|
||||||
|
try:
|
||||||
|
delay = float(delay)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
errors["base"] = "invalid_digit_delay"
|
||||||
|
delay = None
|
||||||
|
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||||
|
errors["base"] = "invalid_digit_delay"
|
||||||
|
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||||
|
icons: dict[str, str] = {}
|
||||||
|
if app_map_text is None:
|
||||||
|
app_map = self._options.get(CONF_APP_NAME_MAP, {})
|
||||||
|
elif app_map_text.strip() == "":
|
||||||
|
app_map = {}
|
||||||
|
else:
|
||||||
|
app_map = _app_map_from_text(app_map_text, icons)
|
||||||
|
if app_map is None:
|
||||||
|
errors["base"] = "invalid_app_map"
|
||||||
|
if not errors:
|
||||||
|
if delay is not None:
|
||||||
|
self._options[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||||
|
self._options.update(user_input)
|
||||||
|
self._options[CONF_APP_NAME_MAP] = app_map
|
||||||
|
# P5-2: the icon column — the editor is the source of
|
||||||
|
# truth, so a save without icons clears them (and a
|
||||||
|
# single-column map stays valid).
|
||||||
|
self._options[CONF_APP_ICON_MAP] = icons
|
||||||
|
return self.async_create_entry(title="", data=self._options)
|
||||||
|
|
||||||
sel = _build_selectors()
|
sel = _build_selectors()
|
||||||
schema_dict = {}
|
schema_dict = {}
|
||||||
|
|
||||||
|
# Apps name/icon map editor (P4-1, core androidtv_remote options
|
||||||
|
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
|
||||||
|
# the optional icon column).
|
||||||
|
current_map = self._options.get(CONF_APP_NAME_MAP, {})
|
||||||
|
current_icons = self._options.get(CONF_APP_ICON_MAP, {})
|
||||||
|
if current_map:
|
||||||
|
app_map_default = _app_map_to_text(current_map, current_icons)
|
||||||
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP, default=app_map_default)] = str
|
||||||
|
else:
|
||||||
|
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||||
|
|
||||||
# Jellyfin
|
# Jellyfin
|
||||||
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
||||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||||
@@ -271,6 +582,21 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
||||||
schema_dict[recv_key] = sel["receiver"]
|
schema_dict[recv_key] = sel["receiver"]
|
||||||
|
|
||||||
|
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||||
|
current_delay = self._options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
|
||||||
|
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY, default=current_delay)] = str
|
||||||
|
current_enter = self._options.get(CONF_CHANNEL_SEND_ENTER, False)
|
||||||
|
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER, default=current_enter)] = bool
|
||||||
|
|
||||||
|
# P5-3: the live events push is opt-in (default off; the user
|
||||||
|
# tests stability on their device and reports)
|
||||||
|
current_logcat = self._options.get(CONF_LOGCAT_EVENTS, False)
|
||||||
|
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS, default=current_logcat)] = bool
|
||||||
|
|
||||||
|
# P4-2a: the raw adb_command service is opt-in (default off)
|
||||||
|
current_flag = self._options.get(CONF_ADB_COMMAND_ENABLED, False)
|
||||||
|
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED, default=current_flag)] = bool
|
||||||
|
|
||||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||||
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
||||||
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
||||||
@@ -324,4 +650,5 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=vol.Schema(schema_dict),
|
data_schema=vol.Schema(schema_dict),
|
||||||
|
errors=errors,
|
||||||
)
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -22,17 +22,124 @@ CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
|||||||
CONF_ADB_HOST = "adb_host"
|
CONF_ADB_HOST = "adb_host"
|
||||||
CONF_ADB_PORT = "adb_port"
|
CONF_ADB_PORT = "adb_port"
|
||||||
|
|
||||||
|
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||||
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||||
|
CONF_PROTOCOL_HOST = "protocol_host"
|
||||||
|
CONF_PAIRING_CODE = "pairing_code"
|
||||||
|
DEFAULT_PROTOCOL_API_PORT = 6466
|
||||||
|
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
||||||
|
|
||||||
ADB_POLL_INTERVAL = 10 # seconds
|
ADB_POLL_INTERVAL = 10 # seconds
|
||||||
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
||||||
DEFAULT_ADB_PORT = 5555
|
DEFAULT_ADB_PORT = 5555
|
||||||
|
|
||||||
|
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
|
||||||
|
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
|
||||||
|
# fields expire (B5) instead of clinging to stale values.
|
||||||
|
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
|
||||||
|
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
||||||
|
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
||||||
|
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||||
|
|
||||||
DEFAULT_NAME = "Nvidia Shield"
|
DEFAULT_NAME = "Nvidia Shield"
|
||||||
|
|
||||||
# Emotiva DAG attribute we read to get available inputs
|
# Emotiva DAG attribute we read to get available inputs
|
||||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||||
|
|
||||||
# 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 = [
|
JELLYFIN_APP_IDS = [
|
||||||
"org.jellyfin.androidtv",
|
"org.jellyfin.androidtv",
|
||||||
"org.jellyfin.mobile",
|
"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
|
||||||
|
|
||||||
|
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
|
||||||
|
# (same security posture as the core androidtv integration).
|
||||||
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
||||||
|
SERVICE_ADB_COMMAND = "adb_command"
|
||||||
|
|
||||||
|
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
||||||
|
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
||||||
|
# reports stability on their device.
|
||||||
|
CONF_LOGCAT_EVENTS = "logcat_events"
|
||||||
|
|
||||||
|
# P5-3: duplicate START for the same package inside this window is not
|
||||||
|
# re-stamped (repeat suppression).
|
||||||
|
LOGCAT_SUPPRESS_WINDOW = 15.0
|
||||||
|
|
||||||
|
# P4-2c BrowseMedia app directory fallback when the options map is empty
|
||||||
|
# (curated; the packages are the plan's reference apps).
|
||||||
|
DEFAULT_APP_DIRECTORY = {
|
||||||
|
"ar.tvplayer.tv": "TiviMate",
|
||||||
|
"com.github.damontecres.wholphin": "Wholphin",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
"com.google.android.youtube.tv": "YouTube",
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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 .connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||||
|
_REDACTED = "**REDACTED**"
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(obj):
|
||||||
|
"""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"] = {
|
||||||
|
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||||
|
"protocol": {
|
||||||
|
"active": manager.protocol_active,
|
||||||
|
"available": manager.is_available,
|
||||||
|
"is_on": manager.is_on,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return _redact(payload)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["async_get_config_entry_diagnostics"]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shared ADB utilities for Home Assistant custom integrations."""
|
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||||
|
|
||||||
from .adb_client import AdbClientBase
|
from .adb_client import AdbClientBase, parse_wakefulness
|
||||||
|
from .app_label import parse_application_label
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
@@ -9,9 +10,16 @@ from .const import (
|
|||||||
ATTR_MEDIA_DAG_ROLE,
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
)
|
)
|
||||||
|
from .foreground import parse_foreground
|
||||||
from .intent import PlaybackIntent
|
from .intent import PlaybackIntent
|
||||||
from .keys import ensure_adb_key
|
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
|
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -26,6 +34,11 @@ __all__ = [
|
|||||||
"PlaybackIntent",
|
"PlaybackIntent",
|
||||||
"ensure_adb_key",
|
"ensure_adb_key",
|
||||||
"parse_all_media_sessions",
|
"parse_all_media_sessions",
|
||||||
|
"parse_application_label",
|
||||||
|
"parse_audio_playback",
|
||||||
|
"parse_foreground",
|
||||||
|
"foreground_from_logcat",
|
||||||
|
"parse_wakefulness",
|
||||||
"parse_media_session",
|
"parse_media_session",
|
||||||
"running_apps_from_sessions",
|
"running_apps_from_sessions",
|
||||||
"running_apps_from_tasks",
|
"running_apps_from_tasks",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||||
|
|
||||||
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
|
|||||||
|
|
||||||
ALL_KEYCODES = {**MEDIA_KEYCODES, **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:
|
class AdbClientBase:
|
||||||
"""Base ADB client with connection management and common commands."""
|
"""Base ADB client with connection management and common commands."""
|
||||||
@@ -43,12 +72,28 @@ class AdbClientBase:
|
|||||||
|
|
||||||
# Media session state
|
# Media session state
|
||||||
self._last_info: MediaSessionInfo | None = None
|
self._last_info: MediaSessionInfo | None = None
|
||||||
|
self._last_dump: str | None = None
|
||||||
self._all_sessions: list[MediaSessionInfo] = []
|
self._all_sessions: list[MediaSessionInfo] = []
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self._connected
|
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
|
@property
|
||||||
def last_info(self) -> MediaSessionInfo | None:
|
def last_info(self) -> MediaSessionInfo | None:
|
||||||
return self._last_info
|
return self._last_info
|
||||||
@@ -60,6 +105,7 @@ class AdbClientBase:
|
|||||||
def clear_info(self) -> None:
|
def clear_info(self) -> None:
|
||||||
"""Clear the cached media session info."""
|
"""Clear the cached media session info."""
|
||||||
self._last_info = None
|
self._last_info = None
|
||||||
|
self._last_dump = None
|
||||||
self._all_sessions = []
|
self._all_sessions = []
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
@@ -90,18 +136,15 @@ class AdbClientBase:
|
|||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Disconnect from the device."""
|
"""Disconnect from the device."""
|
||||||
if self._device:
|
if self._device:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await self._device.close()
|
await self._device.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._device = None
|
self._device = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
async def shell(self, cmd: str) -> str | None:
|
async def shell(self, cmd: str) -> str | None:
|
||||||
"""Run a shell command, reconnecting if needed."""
|
"""Run a shell command, reconnecting if needed."""
|
||||||
if not self._connected or not self._device:
|
if (not self._connected or not self._device) and not await self.connect():
|
||||||
if not await self.connect():
|
return None
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
return await self._device.shell(cmd)
|
return await self._device.shell(cmd)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -135,6 +178,24 @@ class AdbClientBase:
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
"""Bring an app to the foreground using monkey command."""
|
"""Bring an app to the foreground using monkey command."""
|
||||||
result = await self.shell(
|
result = await self.shell(
|
||||||
@@ -145,27 +206,60 @@ class AdbClientBase:
|
|||||||
return result is not None
|
return result is not None
|
||||||
|
|
||||||
async def is_awake(self) -> bool | 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")
|
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||||
if result:
|
return parse_wakefulness(result)
|
||||||
return "Awake" in result
|
|
||||||
return None
|
|
||||||
|
|
||||||
# -- Media session polling --
|
# -- Media session polling --
|
||||||
|
|
||||||
async def poll_media_sessions(
|
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:
|
) -> MediaSessionInfo | None:
|
||||||
"""Query media sessions. Stores all sessions and returns the best match."""
|
"""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}"
|
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:
|
if dump is None:
|
||||||
return None
|
return None
|
||||||
self._all_sessions = parse_all_media_sessions(dump)
|
self._all_sessions = parse_all_media_sessions(dump)
|
||||||
self._last_info = parse_media_session(dump, foreground_package)
|
self._last_info = parse_media_session(dump, foreground_package)
|
||||||
return self._last_info
|
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) --
|
# -- Running apps (recent visible tasks) --
|
||||||
|
|
||||||
async def poll_running_apps(self) -> list[dict]:
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
@@ -182,12 +276,11 @@ class AdbClientBase:
|
|||||||
if not result:
|
if not result:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
import re
|
|
||||||
apps = []
|
apps = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
# System packages to exclude
|
# System packages to exclude
|
||||||
_EXCLUDED = {
|
excluded = {
|
||||||
"com.android.systemui",
|
"com.android.systemui",
|
||||||
"com.android.tv.settings",
|
"com.android.tv.settings",
|
||||||
"com.nvidia.diagtools",
|
"com.nvidia.diagtools",
|
||||||
@@ -206,7 +299,7 @@ class AdbClientBase:
|
|||||||
match = re.search(r"I=(\S+)/", stripped)
|
match = re.search(r"I=(\S+)/", stripped)
|
||||||
if match:
|
if match:
|
||||||
pkg = match.group(1)
|
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)
|
seen.add(pkg)
|
||||||
apps.append({"package": pkg})
|
apps.append({"package": pkg})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""App friendly-name resolution from the package manifest (P5-1).
|
||||||
|
|
||||||
|
Parses `pm dump <package>` output for the ``application-label`` line — the
|
||||||
|
default-locale variant; localized suffixed variants
|
||||||
|
(``application-label-<lang>:``) are skipped, and multi-label blocks across
|
||||||
|
locales are tolerated (first default-locale label wins), mirroring the
|
||||||
|
tolerant pattern of the androidtv-adb core helper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_APPLICATION_LABEL_RE = re.compile(
|
||||||
|
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||||
|
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_application_label(dump: str) -> str | None:
|
||||||
|
"""The package's default-locale application label, or None.
|
||||||
|
|
||||||
|
Returns None on empty/failed dumps too — callers fall through to the
|
||||||
|
next name tier.
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return None
|
||||||
|
for match in _APPLICATION_LABEL_RE.finditer(dump):
|
||||||
|
label = (match.group(1) or match.group(2) or "").strip()
|
||||||
|
if label and label not in ("null", ""):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
|
||||||
|
|
||||||
|
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
|
||||||
|
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
|
||||||
|
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
|
||||||
|
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
|
||||||
|
|
||||||
|
Multi-line ActivityRecord wraps are handled by joining the block until the
|
||||||
|
record closes or a state line arrives (R4 finding 3: match on the joined
|
||||||
|
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_RESUMED_MARKERS = (
|
||||||
|
("mResumedActivity", "mResumedActivity:"),
|
||||||
|
("topResumedActivity", "topResumedActivity:"),
|
||||||
|
("ResumedActivity", "ResumedActivity:"),
|
||||||
|
("Resumed", "Resumed:"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
|
||||||
|
# anything up to the first `/` (or closing brace when the activity is bare).
|
||||||
|
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_kind(stripped: str) -> str | None:
|
||||||
|
for kind, marker in _RESUMED_MARKERS:
|
||||||
|
if stripped.startswith(marker):
|
||||||
|
return kind
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
|
||||||
|
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
|
||||||
|
blocks: list[tuple[str, str]] = []
|
||||||
|
kind: str | None = None
|
||||||
|
buf = ""
|
||||||
|
for raw in dump.split("\n"):
|
||||||
|
stripped = raw.strip()
|
||||||
|
marker = _marker_kind(stripped)
|
||||||
|
if marker is not None:
|
||||||
|
if buf:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
kind, buf = marker, stripped
|
||||||
|
continue
|
||||||
|
if kind is None:
|
||||||
|
continue
|
||||||
|
# Continuation of a wrapped ActivityRecord: join until the record
|
||||||
|
# closes (brace) or the state line completes the block (R4 finding 3).
|
||||||
|
buf += " " + stripped
|
||||||
|
if "}" in stripped or "state=" in stripped:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
kind, buf = None, ""
|
||||||
|
if buf:
|
||||||
|
blocks.append((kind, buf))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_package(text: str) -> str | None:
|
||||||
|
match = _PACKAGE_RE.search(text)
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
package = match.group(1) or match.group(2)
|
||||||
|
if not package or package in ("null", "Window{"):
|
||||||
|
return None
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def parse_foreground(dump: str) -> str | None:
|
||||||
|
"""Foreground package: mResumedActivity primary, topResumed fallback.
|
||||||
|
|
||||||
|
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
|
||||||
|
callers must not fall back to window-focus parsing (R4 finding 2).
|
||||||
|
"""
|
||||||
|
if not dump:
|
||||||
|
return None
|
||||||
|
blocks = _resumed_blocks(dump)
|
||||||
|
for kind, text in blocks:
|
||||||
|
if kind == "topResumedActivity":
|
||||||
|
continue
|
||||||
|
package = _extract_package(text)
|
||||||
|
if package:
|
||||||
|
return package
|
||||||
|
for kind, text in blocks:
|
||||||
|
if kind != "topResumedActivity":
|
||||||
|
continue
|
||||||
|
package = _extract_package(text)
|
||||||
|
if package:
|
||||||
|
return package
|
||||||
|
return None
|
||||||
@@ -13,20 +13,34 @@ class PlaybackIntent:
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
target_state: The MediaPlayerState we expect after the command.
|
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.
|
created_at: Monotonic timestamp when the intent was created.
|
||||||
prior_state: The source state before the intent (for detecting external overrides).
|
prior_state: The source state before the intent (for detecting external overrides).
|
||||||
retry_count: Number of retries attempted so far.
|
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")
|
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
||||||
command: str
|
command: str
|
||||||
created_at: float
|
created_at: float
|
||||||
prior_state: str | None = None
|
prior_state: str | None = None
|
||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
|
via_adb: bool = False
|
||||||
|
adb_command: str | None = None
|
||||||
|
via_protocol: bool = False
|
||||||
|
protocol_key: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@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(
|
return cls(
|
||||||
target_state=target_state,
|
target_state=target_state,
|
||||||
command=command,
|
command=command,
|
||||||
@@ -45,4 +59,6 @@ class PlaybackIntent:
|
|||||||
"command": self.command,
|
"command": self.command,
|
||||||
"retries": self.retry_count,
|
"retries": self.retry_count,
|
||||||
"age_s": self.age_seconds,
|
"age_s": self.age_seconds,
|
||||||
|
"via_adb": self.via_adb,
|
||||||
|
"adb_command": self.adb_command,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
|
||||||
|
|
||||||
|
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
|
||||||
|
the ``cmp=package`` part so the connection manager can stamp the foreground
|
||||||
|
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
|
||||||
|
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
|
||||||
|
source) is untouched: the stream never reads playback state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
|
||||||
|
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
|
||||||
|
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
|
||||||
|
|
||||||
|
|
||||||
|
def start_records(lines: list[str]) -> list[str]:
|
||||||
|
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
|
||||||
|
|
||||||
|
A record opens at a line that matches a log timestamp and mentions
|
||||||
|
ActivityTaskManager + START; subsequent lines that do NOT look like a
|
||||||
|
fresh log record are treated as continuations (the intent JSON can wrap
|
||||||
|
across lines). Anything else closes/open the next record.
|
||||||
|
"""
|
||||||
|
records: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
current = [line]
|
||||||
|
elif current and not _LOG_RECORD_RE.match(line):
|
||||||
|
current.append(line) # wrapped intent text
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
current = []
|
||||||
|
if current:
|
||||||
|
records.append("\n".join(current))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_from_logcat(lines: list[str]) -> str | None:
|
||||||
|
"""The foreground package from the LAST valid START record, or None.
|
||||||
|
|
||||||
|
Non-START lines are ignored entirely; records whose intent carries no
|
||||||
|
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
|
||||||
|
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
|
||||||
|
tolerated — the user prefix is never part of the cmp value.
|
||||||
|
"""
|
||||||
|
package: str | None = None
|
||||||
|
for record in start_records(lines):
|
||||||
|
match = _CMP_RE.search(record)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and candidate != "null":
|
||||||
|
package = candidate # last valid START wins
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def foreground_packages(lines: list[str]) -> list[str]:
|
||||||
|
"""Every distinct valid START package in order (test seam)."""
|
||||||
|
seen: list[str] = []
|
||||||
|
for record in start_records(lines):
|
||||||
|
match = _CMP_RE.search(record)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidate = match.group(1)
|
||||||
|
if candidate and candidate != "null" and candidate not in seen:
|
||||||
|
seen.append(candidate)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"foreground_from_logcat",
|
||||||
|
"foreground_packages",
|
||||||
|
"start_records",
|
||||||
|
]
|
||||||
@@ -4,12 +4,20 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
|
|||||||
information from any app using Android's MediaSession API.
|
information from any app using Android's MediaSession API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MediaSessionInfo:
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
artist: str | None = None
|
artist: str | None = None
|
||||||
@@ -17,6 +25,56 @@ class MediaSessionInfo:
|
|||||||
package: str | None = None
|
package: str | None = None
|
||||||
playing: bool = False
|
playing: bool = False
|
||||||
paused: bool = False
|
paused: 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]:
|
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||||
@@ -32,6 +90,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
current_pkg: str | None = None
|
current_pkg: str | None = None
|
||||||
in_active = False
|
in_active = False
|
||||||
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
current_state = 0 # 0=unknown, 2=paused, 3=playing
|
||||||
|
current_position: float | None = None
|
||||||
|
current_speed: float | None = None
|
||||||
|
current_updated: int | None = None
|
||||||
current_meta: MediaSessionInfo | None = None
|
current_meta: MediaSessionInfo | None = None
|
||||||
|
|
||||||
for line in dump.split("\n"):
|
for line in dump.split("\n"):
|
||||||
@@ -47,16 +108,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
current_pkg = pkg[1].strip()
|
current_pkg = pkg[1].strip()
|
||||||
in_active = False
|
in_active = False
|
||||||
current_state = 0
|
current_state = 0
|
||||||
|
current_position = None
|
||||||
|
current_speed = None
|
||||||
|
current_updated = None
|
||||||
current_meta = None
|
current_meta = None
|
||||||
|
|
||||||
if "active=true" in stripped:
|
if "active=true" in stripped:
|
||||||
in_active = True
|
in_active = True
|
||||||
|
|
||||||
if in_active and "state=PlaybackState" in stripped:
|
if in_active and "state=PlaybackState" in stripped:
|
||||||
if "state=3" in stripped:
|
match = _PLAYBACK_STATE_RE.search(stripped)
|
||||||
current_state = 3
|
if match:
|
||||||
elif "state=2" in stripped:
|
fields = _parse_playback_state(match.group(1))
|
||||||
current_state = 2
|
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:
|
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
||||||
desc_start = stripped.find("description=")
|
desc_start = stripped.find("description=")
|
||||||
@@ -69,6 +139,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
info.package = current_pkg
|
info.package = current_pkg
|
||||||
info.playing = current_state == 3
|
info.playing = current_state == 3
|
||||||
info.paused = current_state == 2
|
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", ""):
|
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
||||||
info.title = parts[0]
|
info.title = parts[0]
|
||||||
@@ -97,6 +170,34 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
|||||||
return sessions
|
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(
|
def parse_media_session(
|
||||||
dump: str, foreground_package: str | None = None
|
dump: str, foreground_package: str | None = None
|
||||||
) -> MediaSessionInfo | None:
|
) -> MediaSessionInfo | None:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
|
|||||||
recent_tasks: list[dict],
|
recent_tasks: list[dict],
|
||||||
media_sessions: list[MediaSessionInfo] | None = None,
|
media_sessions: list[MediaSessionInfo] | None = None,
|
||||||
foreground_package: str | None = None,
|
foreground_package: str | None = None,
|
||||||
|
last_known: dict[str, MediaSessionInfo] | None = None,
|
||||||
) -> list[dict] | None:
|
) -> list[dict] | None:
|
||||||
"""Build a list of running app dicts from recent tasks + media sessions.
|
"""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.
|
recent_tasks: List of {"package": str} from ADB recent tasks.
|
||||||
media_sessions: Optional media sessions for playback state enrichment.
|
media_sessions: Optional media sessions for playback state enrichment.
|
||||||
foreground_package: The current foreground app package.
|
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
|
Returns None if there are fewer than 2 apps (no point showing a switcher
|
||||||
with only one option).
|
with only one option).
|
||||||
@@ -57,6 +61,15 @@ def running_apps_from_tasks(
|
|||||||
app["artist"] = session.artist
|
app["artist"] = session.artist
|
||||||
else:
|
else:
|
||||||
app["state"] = "running"
|
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.paused:
|
||||||
|
app["last_known_state"] = "paused"
|
||||||
|
if cached.title:
|
||||||
|
app["last_known_title"] = cached.title
|
||||||
|
|
||||||
apps.append(app)
|
apps.append(app)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Shared config helpers for the NVIDIA Shield integration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
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 discover_adb_host(
|
||||||
|
hass: HomeAssistant, androidtv_entity_id: str | None
|
||||||
|
) -> str | None:
|
||||||
|
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
|
||||||
|
if not androidtv_entity_id:
|
||||||
|
return None
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
entity_entry = registry.async_get(androidtv_entity_id)
|
||||||
|
if not entity_entry or not entity_entry.config_entry_id:
|
||||||
|
return None
|
||||||
|
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||||
|
if config_entry:
|
||||||
|
return config_entry.data.get("host")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||||
|
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||||
|
|
||||||
|
Priority: explicit ADB host/port options > host from the AndroidTV
|
||||||
|
entity's own config entry.
|
||||||
|
"""
|
||||||
|
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||||
|
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||||
|
)
|
||||||
|
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))
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
{
|
{
|
||||||
"domain": "nvidia_shield",
|
"domain": "nvidia_shield",
|
||||||
"name": "NVIDIA Shield",
|
"name": "NVIDIA Shield",
|
||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@ltomes"
|
"@notexpectedyet"
|
||||||
],
|
],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
|
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
|
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"adb-shell>=0.4.0"
|
"adb-shell>=0.4.0",
|
||||||
],
|
"androidtvremote2>=0.2.0"
|
||||||
"version": "0.1.1"
|
],
|
||||||
|
"version": "2026.10.08",
|
||||||
|
"zeroconf": [
|
||||||
|
"_androidtvremote2._tcp.local."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
"""Media player entity for Nvidia Shield.
|
"""Media player entity for Nvidia Shield.
|
||||||
|
|
||||||
|
Phase 2 architecture (docs/MVP_PLAN.md §2): the entry's ShieldConnectionManager
|
||||||
|
(connection.py) owns both transports — ADB (adb_shell, state source) and the
|
||||||
|
absorbed remote protocol (androidtvremote2, local push) — plus their StateStore;
|
||||||
|
this entity is a thin callback-view over it.
|
||||||
|
|
||||||
State architecture:
|
State architecture:
|
||||||
- Power/availability: androidtv_remote (local push, fast)
|
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
||||||
- Playback state: Jellyfin (polling, slow 10-30s)
|
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
||||||
- Metadata: Jellyfin attributes
|
- Metadata: Jellyfin attributes
|
||||||
- ALL commands: androidtv_remote (Jellyfin is read-only)
|
- Commands: protocol key push → ADB keyevent → external androidtv_remote
|
||||||
|
(the last tier is transitional; removed in Phase 4)
|
||||||
|
|
||||||
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||||
1. User presses pause → intent recorded → UI shows paused immediately
|
1. User presses pause → intent recorded → UI shows paused immediately
|
||||||
2. Command sent to androidtv_remote → Shield pauses on screen
|
2. Command sent over the serving transport → Shield pauses on screen
|
||||||
3. 10-30s later Jellyfin confirms paused → intent cleared
|
3. 10-30s later Jellyfin confirms paused → intent cleared
|
||||||
4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth
|
4. If Jellyfin doesn't confirm in 30s → retry once on the serving
|
||||||
|
transport (protocol/ADB stamped on the intent) → surrender to truth
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
MediaPlayerDeviceClass,
|
MediaPlayerDeviceClass,
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
@@ -25,34 +32,40 @@ from homeassistant.components.media_player import (
|
|||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event
|
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
ADB_POLL_INTERVAL,
|
|
||||||
ADB_RETRY_INTERVAL,
|
|
||||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||||
ATTR_MEDIA_DAG_ROLE,
|
ATTR_MEDIA_DAG_ROLE,
|
||||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||||
CONF_ADB_ENABLED,
|
CHANNEL_DIGIT_DELAY,
|
||||||
CONF_ADB_HOST,
|
CONF_ADB_COMMAND_ENABLED,
|
||||||
CONF_ADB_PORT,
|
|
||||||
CONF_ADB_STORE_APP_INFO,
|
CONF_ADB_STORE_APP_INFO,
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_APP_ICON_MAP,
|
||||||
|
CONF_APP_NAME_MAP,
|
||||||
|
CONF_CHANNEL_DIGIT_DELAY,
|
||||||
|
CONF_CHANNEL_SEND_ENTER,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
CONF_POWER_ENTITY,
|
CONF_POWER_ENTITY,
|
||||||
CONF_RECEIVER_ENTITY,
|
CONF_RECEIVER_ENTITY,
|
||||||
CONF_RECEIVER_INPUT,
|
CONF_RECEIVER_INPUT,
|
||||||
DEFAULT_ADB_PORT,
|
DEFAULT_APP_DIRECTORY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
PROTOCOL_MEDIA_KEYS,
|
||||||
|
PROTOCOL_REMOTE_KEYS,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
TIVIMATE_PACKAGE,
|
||||||
)
|
)
|
||||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||||
from .jellyfin import fetch_session_media_info
|
from .jellyfin import fetch_session_media_info
|
||||||
from .power import discover_power_siblings
|
from .power import discover_power_siblings
|
||||||
|
|
||||||
@@ -73,6 +86,8 @@ _BASE_FEATURES = (
|
|||||||
| MediaPlayerEntityFeature.TURN_OFF
|
| MediaPlayerEntityFeature.TURN_OFF
|
||||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||||
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||||
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||||
)
|
)
|
||||||
|
|
||||||
_PLAYBACK_FEATURES = (
|
_PLAYBACK_FEATURES = (
|
||||||
@@ -90,6 +105,49 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
|
||||||
|
# P5-1: one-time legacy unique_id migration (t20 F3 closure)
|
||||||
|
hass.async_create_task(_migrate_legacy_unique_id(hass, entry))
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_legacy_unique_id(hass, entry) -> None:
|
||||||
|
"""Migrate legacy androidtv-entity-id unique_ids once (t20 F3).
|
||||||
|
|
||||||
|
Legacy entries carry the androidtv entity id as unique_id; the one-time
|
||||||
|
migration moves it to the protocol MAC when the transport can resolve
|
||||||
|
it, else to the config-entry id. After the single update_entry the
|
||||||
|
legacy pattern no longer matches, so this never runs twice for an
|
||||||
|
entry. No behavior change: the entry's data/options are untouched.
|
||||||
|
"""
|
||||||
|
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
|
if not legacy or entry.unique_id != legacy:
|
||||||
|
return
|
||||||
|
manager = getattr(entry, "runtime_data", None)
|
||||||
|
new_id = None
|
||||||
|
if manager is not None:
|
||||||
|
info = await manager.async_protocol_device_info()
|
||||||
|
if info is not None:
|
||||||
|
_name, new_id = info
|
||||||
|
if not new_id:
|
||||||
|
new_id = entry.entry_id
|
||||||
|
# t26 F4: another entry may already own the target MAC — the collision
|
||||||
|
# pre-check falls back to the config-entry id instead of re-binding the
|
||||||
|
# unique_id to a MAC that could collide with a real second instance.
|
||||||
|
other_entries = getattr(
|
||||||
|
getattr(hass, "config_entries", None), "entries", []
|
||||||
|
) or []
|
||||||
|
if any(
|
||||||
|
getattr(other, "unique_id", None) == new_id
|
||||||
|
for other in other_entries
|
||||||
|
if getattr(other, "entry_id", None) != entry.entry_id
|
||||||
|
):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"P5-1: unique_id migration target %r already in use — falling "
|
||||||
|
"back to the config-entry id",
|
||||||
|
new_id,
|
||||||
|
)
|
||||||
|
new_id = entry.entry_id
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=new_id)
|
||||||
|
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||||
@@ -98,11 +156,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
# Overridden as a property below: ADB-health aware
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
||||||
self._unsub_listeners: list = []
|
self._unsub_listeners: list = []
|
||||||
|
|
||||||
@@ -116,11 +175,32 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
# Jellyfin media stream info (resolution, codec, etc.)
|
# Jellyfin media stream info (resolution, codec, etc.)
|
||||||
self._media_info: dict | None = None
|
self._media_info: dict | None = None
|
||||||
|
|
||||||
# ADB media session, wake state, and running apps
|
# ADB-derived state comes from the entry's ShieldConnectionManager
|
||||||
self._adb_poller: AdbMediaSessionPoller | None = None
|
# (connection.py) — this entity is a thin callback-view over its store.
|
||||||
self._adb_poll_unsub: CALLBACK_TYPE | None = None
|
self._manager: ShieldConnectionManager | None = None
|
||||||
self._adb_awake: bool | None = None
|
self._manager_unsub: CALLBACK_TYPE | None = None
|
||||||
self._running_apps: list[dict] = []
|
# In-flight channel digit entry (play_media(channel) — §7); a new
|
||||||
|
# play_media cancels the previous sequence (core task-cancel guard).
|
||||||
|
self._channel_task = None
|
||||||
|
# Last channel entered via play_media(channel) — the foreground
|
||||||
|
# tier's title fallback when an app publishes no session (P3-3a).
|
||||||
|
self._last_channel: str | None = None
|
||||||
|
# Entity-owned background tasks (intent retries, media-info fetch);
|
||||||
|
# cancelled on unload. The manager owns the socket — never touch it
|
||||||
|
# here directly.
|
||||||
|
self._entity_tasks: set = set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""The bridge entities stay functional without ADB (passive manager).
|
||||||
|
|
||||||
|
Unavailable only while an active ADB manager is down — its data is
|
||||||
|
unreliable until the watchdog reconnects (t5-F2).
|
||||||
|
"""
|
||||||
|
manager = getattr(self._entry, "runtime_data", None)
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return True
|
||||||
|
return manager.connected or manager.awake is not None or not manager.available
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _jellyfin_entity(self) -> str | None:
|
def _jellyfin_entity(self) -> str | None:
|
||||||
@@ -147,22 +227,35 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
def _power_entity(self) -> str | None:
|
def _power_entity(self) -> str | None:
|
||||||
return self._entry.options.get(CONF_POWER_ENTITY) or None
|
return self._entry.options.get(CONF_POWER_ENTITY) or None
|
||||||
|
|
||||||
@property
|
|
||||||
def _adb_enabled(self) -> bool:
|
|
||||||
return self._entry.options.get(CONF_ADB_ENABLED, False)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _adb_store_info(self) -> bool:
|
def _adb_store_info(self) -> bool:
|
||||||
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
|
# P4-2a: opt-in as an adb_command target (option default off).
|
||||||
|
# Registered at setup; unregistered at unload when the last
|
||||||
|
# participating entity goes away.
|
||||||
|
if self._entry.options.get(CONF_ADB_COMMAND_ENABLED, False):
|
||||||
|
# F4: the registry key is computed once and stored on the
|
||||||
|
# entity, so add/remove always agree even when entity_id is
|
||||||
|
# assigned by HA core after the first read.
|
||||||
|
self._adb_command_key = (
|
||||||
|
getattr(self, "entity_id", None) or self._attr_unique_id
|
||||||
|
)
|
||||||
|
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
|
||||||
|
_adb_command_service_ensure(self.hass)
|
||||||
|
|
||||||
# Discover power sensor siblings from the power switch
|
# Discover power sensor siblings from the power switch
|
||||||
if self._power_entity:
|
if self._power_entity:
|
||||||
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
||||||
|
|
||||||
# Set up ADB media session poller
|
# Consume the entry's ShieldConnectionManager (thin-view pattern)
|
||||||
if self._adb_enabled:
|
manager = getattr(self._entry, "runtime_data", None)
|
||||||
await self._setup_adb()
|
if manager is not None:
|
||||||
|
self._manager = manager
|
||||||
|
self._manager_unsub = manager.register_listener(
|
||||||
|
self._on_manager_update
|
||||||
|
)
|
||||||
|
|
||||||
entities = [self._androidtv_entity]
|
entities = [self._androidtv_entity]
|
||||||
if self._jellyfin_entity:
|
if self._jellyfin_entity:
|
||||||
@@ -180,11 +273,21 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
unsub()
|
unsub()
|
||||||
self._unsub_listeners.clear()
|
self._unsub_listeners.clear()
|
||||||
self._clear_intent()
|
self._clear_intent()
|
||||||
if self._adb_poll_unsub:
|
if self._manager_unsub:
|
||||||
self._adb_poll_unsub()
|
self._manager_unsub()
|
||||||
self._adb_poll_unsub = None
|
self._manager_unsub = None
|
||||||
if self._adb_poller:
|
# Cancel in-flight entity tasks so they cannot outlive the entity.
|
||||||
await self._adb_poller.disconnect()
|
for task in self._entity_tasks:
|
||||||
|
task.cancel()
|
||||||
|
self._entity_tasks.clear()
|
||||||
|
# P4-2a: drop this entity's service participation (same key as add)
|
||||||
|
if (
|
||||||
|
(key := getattr(self, "_adb_command_key", None)) is not None
|
||||||
|
and _ADB_COMMAND_TARGETS.pop(key, None) is not None
|
||||||
|
and self.hass
|
||||||
|
):
|
||||||
|
_adb_command_service_maybe_remove(self.hass)
|
||||||
|
|
||||||
|
|
||||||
# ── Intent management ──
|
# ── Intent management ──
|
||||||
|
|
||||||
@@ -243,9 +346,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
|
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
|
||||||
intent.command, jf_state, intent.target_state, intent.retry_count,
|
intent.command, jf_state, intent.target_state, intent.retry_count,
|
||||||
)
|
)
|
||||||
self.hass.async_create_task(
|
if (
|
||||||
self._cmd(intent.command)
|
intent.via_protocol
|
||||||
)
|
and intent.protocol_key
|
||||||
|
and self._manager is not None
|
||||||
|
):
|
||||||
|
# The command was served by the protocol key push — retry there.
|
||||||
|
self._track_task(
|
||||||
|
self._manager.send_protocol_key(intent.protocol_key)
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
intent.via_adb
|
||||||
|
and intent.adb_command
|
||||||
|
and self._manager is not None
|
||||||
|
and self._manager.connected
|
||||||
|
):
|
||||||
|
# The command was originally served by ADB — retry over ADB.
|
||||||
|
self._track_task(
|
||||||
|
self._manager.send_media_command(intent.adb_command)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._track_task(
|
||||||
|
self._cmd(intent.command)
|
||||||
|
)
|
||||||
self._intent_timer_unsub = async_call_later(
|
self._intent_timer_unsub = async_call_later(
|
||||||
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||||
)
|
)
|
||||||
@@ -268,11 +391,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
new_state_obj = event.data.get("new_state")
|
new_state_obj = event.data.get("new_state")
|
||||||
|
|
||||||
# Power off clears any intent — device is off, intent is moot
|
# Power off clears any intent — device is off, intent is moot
|
||||||
if entity_id in (self._androidtv_entity, self._power_entity) and new_state_obj:
|
if (
|
||||||
if not self._is_power_on():
|
entity_id in (self._androidtv_entity, self._power_entity)
|
||||||
if self._intent:
|
and new_state_obj
|
||||||
_LOGGER.debug("Intent cleared: device powered off")
|
and not self._is_power_on()
|
||||||
self._clear_intent()
|
and self._intent
|
||||||
|
):
|
||||||
|
_LOGGER.debug("Intent cleared: device powered off")
|
||||||
|
self._clear_intent()
|
||||||
|
|
||||||
# Jellyfin state change — check for intent confirmation and fetch media info
|
# Jellyfin state change — check for intent confirmation and fetch media info
|
||||||
if entity_id == self._jellyfin_entity and new_state_obj:
|
if entity_id == self._jellyfin_entity and new_state_obj:
|
||||||
@@ -296,7 +422,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
# Fetch media stream info when playing
|
# Fetch media stream info when playing
|
||||||
if new_jf in (STATE_PLAYING, STATE_PAUSED):
|
if new_jf in (STATE_PLAYING, STATE_PAUSED):
|
||||||
entity_picture = new_state_obj.attributes.get("entity_picture", "")
|
entity_picture = new_state_obj.attributes.get("entity_picture", "")
|
||||||
self.hass.async_create_task(
|
self._track_task(
|
||||||
self._fetch_media_info(entity_picture)
|
self._fetch_media_info(entity_picture)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -313,108 +439,23 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return None
|
return None
|
||||||
return self.hass.states.get(entity_id)
|
return self.hass.states.get(entity_id)
|
||||||
|
|
||||||
def _discover_adb_host(self) -> str | None:
|
|
||||||
"""Auto-detect ADB host from the androidtv_remote config entry for our entity."""
|
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
|
|
||||||
registry = er.async_get(self.hass)
|
|
||||||
entity_entry = registry.async_get(self._androidtv_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 _setup_adb(self) -> None:
|
|
||||||
"""Initialize ADB connection and start polling."""
|
|
||||||
try:
|
|
||||||
# Only auto-connect if ADB was previously paired (key exists)
|
|
||||||
import os
|
|
||||||
key_dir = os.path.join(self.hass.config.config_dir, ".storage", DOMAIN)
|
|
||||||
key_file = os.path.join(key_dir, "adbkey")
|
|
||||||
if not await self.hass.async_add_executor_job(os.path.exists, key_file):
|
|
||||||
_LOGGER.info(
|
|
||||||
"ADB key not found — skipping auto-connect. "
|
|
||||||
"Use the Pair ADB button or reconfigure the integration."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get device IP: explicit config > auto-detect from androidtv_remote
|
|
||||||
host = self._entry.options.get(CONF_ADB_HOST)
|
|
||||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
|
||||||
|
|
||||||
if not host:
|
|
||||||
host = self._discover_adb_host()
|
|
||||||
|
|
||||||
if not host:
|
|
||||||
_LOGGER.warning("Could not determine ADB host. Set it in integration options.")
|
|
||||||
return
|
|
||||||
|
|
||||||
key_path = key_file
|
|
||||||
self._adb_poller = AdbMediaSessionPoller(host, port, key_path)
|
|
||||||
|
|
||||||
if await self._adb_poller.connect():
|
|
||||||
# Start periodic polling
|
|
||||||
self._adb_poll_unsub = async_call_later(
|
|
||||||
self.hass, ADB_POLL_INTERVAL, self._adb_poll_callback
|
|
||||||
)
|
|
||||||
_LOGGER.info("ADB media session polling started for %s", host)
|
|
||||||
else:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"ADB connection to %s failed. If this is the first connection, "
|
|
||||||
"approve the ADB debugging prompt on the device. "
|
|
||||||
"Retrying in %d seconds.",
|
|
||||||
host, ADB_RETRY_INTERVAL,
|
|
||||||
)
|
|
||||||
self._adb_poll_unsub = async_call_later(
|
|
||||||
self.hass, ADB_RETRY_INTERVAL, self._adb_poll_callback
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Failed to set up ADB")
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _adb_poll_callback(self, _now) -> None:
|
def _on_manager_update(self) -> None:
|
||||||
"""Schedule ADB poll as a task."""
|
"""Connection manager's store changed — re-evaluate our state."""
|
||||||
self.hass.async_create_task(self._adb_poll())
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def _adb_poll(self) -> None:
|
def _track_task(self, coro) -> None:
|
||||||
"""Poll ADB for wake state and media session, then schedule next poll."""
|
"""Dispatch an entity background task; cancelled on unload.
|
||||||
try:
|
|
||||||
if not self._adb_poller:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Always check wake state
|
Entity-owned tasks (intent retries, media-info fetch) must never go
|
||||||
prev_awake = self._adb_awake
|
through a bare hass.async_create_task: they would survive removal
|
||||||
self._adb_awake = await self._adb_poller.is_awake()
|
(reviewer finding F2). The connection manager owns its own tasks and
|
||||||
|
the socket — nothing here touches it directly.
|
||||||
# Only poll media session when awake and Jellyfin is NOT active
|
"""
|
||||||
jf = self._read(self._jellyfin_entity)
|
task = self.hass.async_create_task(coro)
|
||||||
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
self._entity_tasks.add(task)
|
||||||
|
task.add_done_callback(self._entity_tasks.discard)
|
||||||
if self._adb_awake:
|
return task
|
||||||
# Always poll ADB when awake — pass the foreground app
|
|
||||||
# so the parser prefers its session over stale ones
|
|
||||||
await self._adb_poller.poll(foreground_package=self.app_id)
|
|
||||||
self._running_apps = await self._adb_poller.poll_running_apps()
|
|
||||||
elif self._adb_poller.last_info:
|
|
||||||
# Clear media info when asleep
|
|
||||||
self._adb_poller.clear_info()
|
|
||||||
self._running_apps = []
|
|
||||||
|
|
||||||
# Always update state (wake state may have changed)
|
|
||||||
if self._adb_awake != prev_awake or self._adb_poller.last_info:
|
|
||||||
self.async_write_ha_state()
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.debug("ADB poll error", exc_info=True)
|
|
||||||
finally:
|
|
||||||
# Use slow retry if ADB is disconnected, normal interval if connected
|
|
||||||
if self._adb_enabled:
|
|
||||||
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL
|
|
||||||
self._adb_poll_unsub = async_call_later(
|
|
||||||
self.hass, interval, self._adb_poll_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _fetch_media_info(self, entity_picture: str) -> None:
|
async def _fetch_media_info(self, entity_picture: str) -> None:
|
||||||
"""Read stream info from the HA Jellyfin integration's coordinator."""
|
"""Read stream info from the HA Jellyfin integration's coordinator."""
|
||||||
@@ -431,11 +472,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
|
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
|
||||||
return True
|
return True
|
||||||
# If we have an active intent for play/pause, media is conceptually active
|
# If we have an active intent for play/pause, media is conceptually active
|
||||||
if self._intent and self._intent.target_state in (
|
return bool(
|
||||||
STATE_PLAYING, STATE_PAUSED
|
self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED)
|
||||||
):
|
)
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ── State ──
|
# ── State ──
|
||||||
|
|
||||||
@@ -446,11 +485,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
ADB is the authority — it knows if the device is awake or asleep.
|
ADB is the authority — it knows if the device is awake or asleep.
|
||||||
The plug being "on" just means the outlet has power (standby).
|
The plug being "on" just means the outlet has power (standby).
|
||||||
"""
|
"""
|
||||||
# 1. ADB wake state — most accurate
|
# 1. Protocol is_on push — fastest truth when the transport is live
|
||||||
if self._adb_awake is not None:
|
protocol_is_on = self._manager.is_on if self._manager else None
|
||||||
return self._adb_awake
|
if protocol_is_on is not None:
|
||||||
|
return protocol_is_on
|
||||||
|
|
||||||
# 2. Power entity — only trust "off" (plug pulled = definitely off)
|
# 2. ADB wake state — exact mWakefulness parse
|
||||||
|
adb_awake = self._manager.awake if self._manager else None
|
||||||
|
if adb_awake is not None:
|
||||||
|
return adb_awake
|
||||||
|
|
||||||
|
# 3. Power entity — only trust "off" (plug pulled = definitely off)
|
||||||
if self._power_entity:
|
if self._power_entity:
|
||||||
power_state = self._read(self._power_entity)
|
power_state = self._read(self._power_entity)
|
||||||
if power_state and power_state.state == "off":
|
if power_state and power_state.state == "off":
|
||||||
@@ -469,7 +514,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
power = self._is_power_on()
|
power = self._is_power_on()
|
||||||
|
|
||||||
if power is False:
|
if power is False:
|
||||||
return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot)
|
if self._manager is not None and self._manager.is_on is False:
|
||||||
|
return MediaPlayerState.OFF # protocol says powered off
|
||||||
|
# ADB says asleep — standby (yellow dot)
|
||||||
|
return MediaPlayerState.IDLE
|
||||||
|
|
||||||
if power is None:
|
if power is None:
|
||||||
return MediaPlayerState.IDLE
|
return MediaPlayerState.IDLE
|
||||||
@@ -487,13 +535,20 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
return MediaPlayerState.PAUSED
|
return MediaPlayerState.PAUSED
|
||||||
|
|
||||||
# Tier 3: ADB media session (non-Jellyfin apps)
|
# Tier 3: ADB media session (non-Jellyfin apps)
|
||||||
if self._adb_poller and self._adb_poller.last_info:
|
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||||
adb_info = self._adb_poller.last_info
|
if adb_info:
|
||||||
if adb_info.playing:
|
if adb_info.playing:
|
||||||
return MediaPlayerState.PLAYING
|
return MediaPlayerState.PLAYING
|
||||||
if adb_info.paused:
|
if adb_info.paused:
|
||||||
return MediaPlayerState.PAUSED
|
return MediaPlayerState.PAUSED
|
||||||
|
|
||||||
|
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
|
||||||
|
# current_app push, else the ADB mResumedActivity parse) but publishes
|
||||||
|
# no media session. The device is still ON; the now-playing tier
|
||||||
|
# carries the last-known/channel/audio-hint identity.
|
||||||
|
if self._manager is not None and self._manager.foreground_app:
|
||||||
|
return MediaPlayerState.ON
|
||||||
|
|
||||||
# Tier 4: On but no media
|
# Tier 4: On but no media
|
||||||
return MediaPlayerState.ON
|
return MediaPlayerState.ON
|
||||||
|
|
||||||
@@ -502,10 +557,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
if self._jf_active():
|
if self._jf_active():
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
# ADB media session also supports playback controls
|
# ADB media session also supports playback controls
|
||||||
if self._adb_poller and self._adb_poller.last_info:
|
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||||
adb_info = self._adb_poller.last_info
|
if adb_info and (adb_info.playing or adb_info.paused):
|
||||||
if adb_info.playing or adb_info.paused:
|
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
|
||||||
return _BASE_FEATURES
|
return _BASE_FEATURES
|
||||||
|
|
||||||
# ── Metadata from Jellyfin ──
|
# ── Metadata from Jellyfin ──
|
||||||
@@ -516,13 +570,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def app_id(self):
|
def app_id(self):
|
||||||
atv = self._read(self._androidtv_entity)
|
"""Android TV entity first; standalone setups fall back to the
|
||||||
return atv.attributes.get("app_id") if atv else None
|
protocol current-app push (raw package — both stamp the store)."""
|
||||||
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
||||||
|
if atv is not None:
|
||||||
|
app_id = atv.attributes.get("app_id")
|
||||||
|
if app_id:
|
||||||
|
return app_id
|
||||||
|
if self._manager is not None:
|
||||||
|
return self._manager.current_app
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _app_name_map(self) -> dict:
|
||||||
|
"""The user's apps name/icon map (P4-1 options editor)."""
|
||||||
|
return self._entry.options.get(CONF_APP_NAME_MAP) or {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_name(self):
|
def app_name(self):
|
||||||
atv = self._read(self._androidtv_entity)
|
"""P5-1 resolution order — explicit override first:
|
||||||
return atv.attributes.get("app_name") if atv else None
|
|
||||||
|
1. CONF_APP_NAME_MAP (the user's explicit override)
|
||||||
|
2. device manifest label (`pm dump`, cached ≥24 h, resolved on
|
||||||
|
demand — a miss enqueues the fetch and falls through)
|
||||||
|
3. androidtv entity tier (when configured)
|
||||||
|
4. raw package (protocol push / ADB foreground parse)
|
||||||
|
"""
|
||||||
|
package = self._manager.foreground_app if self._manager else None
|
||||||
|
if package:
|
||||||
|
mapped = self._app_name_map.get(package)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
label = self._manager.app_label(package)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None
|
||||||
|
if atv is not None:
|
||||||
|
app_name = atv.attributes.get("app_name")
|
||||||
|
if app_name:
|
||||||
|
return app_name
|
||||||
|
if self._manager is not None and package:
|
||||||
|
return package
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_content_type(self):
|
def media_content_type(self):
|
||||||
@@ -558,7 +647,40 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def media_image_url(self):
|
def media_image_url(self):
|
||||||
return self._jf_attr("entity_picture") if self._jf_active() else None
|
"""Jellyfin session picture, else the P5-2 apps-map icon override."""
|
||||||
|
if self._jf_active():
|
||||||
|
jf = self._jf_attr("entity_picture")
|
||||||
|
if jf:
|
||||||
|
return jf
|
||||||
|
package = self._manager.foreground_app if self._manager else None
|
||||||
|
if package:
|
||||||
|
icon_map = self._entry.options.get(CONF_APP_ICON_MAP, {})
|
||||||
|
return icon_map.get(package)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def volume_level(self) -> float | None:
|
||||||
|
"""Protocol volume push readout (C-VOL — level only, no VOLUME_SET)."""
|
||||||
|
if self._manager is None:
|
||||||
|
return None
|
||||||
|
info = self._manager.volume_info
|
||||||
|
if info is None:
|
||||||
|
return None
|
||||||
|
level, volume_max, _muted = info
|
||||||
|
try:
|
||||||
|
if volume_max and volume_max > 0:
|
||||||
|
return level / volume_max
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_volume_muted(self) -> bool | None:
|
||||||
|
"""Real mute state from the protocol push (was unknowable pre-Phase 2)."""
|
||||||
|
if self._manager is None:
|
||||||
|
return None
|
||||||
|
info = self._manager.volume_info
|
||||||
|
return info[2] if info is not None else None
|
||||||
|
|
||||||
def _get_now_playing(self) -> dict | None:
|
def _get_now_playing(self) -> dict | None:
|
||||||
if not self._jf_active():
|
if not self._jf_active():
|
||||||
@@ -584,11 +706,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
def _get_adb_now_playing(self) -> dict | None:
|
def _get_adb_now_playing(self) -> dict | None:
|
||||||
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
|
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
|
||||||
if not self._adb_poller or not self._adb_poller.last_info:
|
info = self._manager.best_session(self.app_id) if self._manager else None
|
||||||
|
if not info:
|
||||||
return None
|
return None
|
||||||
if not self._adb_store_info:
|
if not self._adb_store_info:
|
||||||
# Privacy mode: show that something is playing but not what
|
# Privacy mode: show that something is playing but not what
|
||||||
info = self._adb_poller.last_info
|
|
||||||
if info.playing or info.paused:
|
if info.playing or info.paused:
|
||||||
return {
|
return {
|
||||||
"state": "playing" if info.playing else "paused",
|
"state": "playing" if info.playing else "paused",
|
||||||
@@ -597,7 +719,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
info = self._adb_poller.last_info
|
|
||||||
if not info.title:
|
if not info.title:
|
||||||
return None
|
return None
|
||||||
np = {
|
np = {
|
||||||
@@ -611,6 +732,42 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
np["album"] = info.album
|
np["album"] = info.album
|
||||||
return np
|
return np
|
||||||
|
|
||||||
|
def _get_app_now_playing(self) -> dict | None:
|
||||||
|
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
|
||||||
|
no media session (TiviMate, R2). Title = last known state, else the
|
||||||
|
last channel entered, else the zero-cost audio-playback hint.
|
||||||
|
"""
|
||||||
|
manager = self._manager
|
||||||
|
if manager is None:
|
||||||
|
return None
|
||||||
|
foreground = manager.foreground_app
|
||||||
|
if not foreground:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state = "idle"
|
||||||
|
title = None
|
||||||
|
info = manager.last_known_session(foreground)
|
||||||
|
if info is not None and (info.playing or info.paused):
|
||||||
|
state = "playing" if info.playing else "paused"
|
||||||
|
# Privacy mode: identity stays the app, never the media title
|
||||||
|
title = None if not self._adb_store_info else info.title
|
||||||
|
if title is None and not self._adb_store_info:
|
||||||
|
# F3: privacy mode first — identity stays the app, so neither the
|
||||||
|
# channel number nor the media title can leak as the identity.
|
||||||
|
title = self.app_name or self.app_id or foreground
|
||||||
|
if title is None and self._last_channel and (
|
||||||
|
# F1: the channel number belongs to the channel app only — never
|
||||||
|
# leak it to the launcher or another foreground app.
|
||||||
|
foreground == TIVIMATE_PACKAGE or foreground in manager.audio_playback
|
||||||
|
):
|
||||||
|
title = self._last_channel
|
||||||
|
if title is None and foreground in manager.audio_playback:
|
||||||
|
# E hint (zero round-trip cost): audio was recently played
|
||||||
|
title = "Media recently active"
|
||||||
|
if title is None:
|
||||||
|
return None # bare foreground app — the state stays ON regardless
|
||||||
|
return {"state": state, "title": title, "source": "app"}
|
||||||
|
|
||||||
def _get_power_data(self) -> dict | None:
|
def _get_power_data(self) -> dict | None:
|
||||||
"""Read current values from discovered power sensors."""
|
"""Read current values from discovered power sensors."""
|
||||||
if not self._power_sensors:
|
if not self._power_sensors:
|
||||||
@@ -629,22 +786,29 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
def _get_running_apps(self) -> list[dict] | None:
|
def _get_running_apps(self) -> list[dict] | None:
|
||||||
"""Build a list of running apps from ADB recent tasks + media sessions."""
|
"""Build a list of running apps from ADB recent tasks + media sessions."""
|
||||||
if not self._adb_poller or not self._running_apps:
|
if not self._manager:
|
||||||
|
return None
|
||||||
|
apps = self._manager.running_apps
|
||||||
|
if not apps:
|
||||||
return None
|
return None
|
||||||
if not self._adb_store_info:
|
if not self._adb_store_info:
|
||||||
return None
|
return None
|
||||||
return running_apps_from_tasks(
|
return running_apps_from_tasks(
|
||||||
self._running_apps,
|
apps,
|
||||||
self._adb_poller.all_sessions,
|
self._manager.active_sessions,
|
||||||
foreground_package=self.app_id,
|
foreground_package=self.app_id,
|
||||||
|
last_known=self._manager.last_known_map(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
# Now playing: prefer Jellyfin, fall back to ADB media session
|
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
||||||
|
# then the foreground tier (P3-3a).
|
||||||
now_playing = self._get_now_playing()
|
now_playing = self._get_now_playing()
|
||||||
if not now_playing:
|
if not now_playing:
|
||||||
now_playing = self._get_adb_now_playing()
|
now_playing = self._get_adb_now_playing()
|
||||||
|
if not now_playing:
|
||||||
|
now_playing = self._get_app_now_playing()
|
||||||
|
|
||||||
attrs = {"app_id": self.app_id, "app_name": self.app_name,
|
attrs = {"app_id": self.app_id, "app_name": self.app_name,
|
||||||
ATTR_MEDIA_DAG_ROLE: "source",
|
ATTR_MEDIA_DAG_ROLE: "source",
|
||||||
@@ -686,16 +850,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
await self._cmd("turn_off")
|
await self._cmd("turn_off")
|
||||||
|
|
||||||
async def async_volume_up(self):
|
async def async_volume_up(self):
|
||||||
await self._cmd("volume_up")
|
await self._remote_key_cmd("volume_up", "volume_up")
|
||||||
|
|
||||||
async def async_volume_down(self):
|
async def async_volume_down(self):
|
||||||
await self._cmd("volume_down")
|
await self._remote_key_cmd("volume_down", "volume_down")
|
||||||
|
|
||||||
async def async_mute_volume(self, mute):
|
async def async_mute_volume(self, mute):
|
||||||
await self._cmd("volume_mute", is_volume_muted=mute)
|
"""Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL)."""
|
||||||
|
await self._remote_key_cmd("volume_mute", "mute")
|
||||||
|
|
||||||
async def _media_cmd(self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None) -> None:
|
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||||
"""Send a media command. Uses ADB dispatch for non-Jellyfin apps when available."""
|
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
|
||||||
|
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
||||||
|
if (
|
||||||
|
protocol_key
|
||||||
|
and self._manager is not None
|
||||||
|
and await self._manager.send_protocol_key(protocol_key)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if self._manager is not None and await self._manager.send_media_command(adb_key):
|
||||||
|
return
|
||||||
|
# Transitional: external androidtv_remote entity (removed in Phase 4)
|
||||||
|
await self._cmd(remote_key)
|
||||||
|
|
||||||
|
async def _media_cmd(
|
||||||
|
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Send a media command over the fastest available command path.
|
||||||
|
|
||||||
|
Order (Phase 2): protocol key push → ADB keyevent → the external
|
||||||
|
androidtv_remote entity (transitional until Phase 4 removes it).
|
||||||
|
"""
|
||||||
if intent_state:
|
if intent_state:
|
||||||
self._set_intent(ha_service, intent_state)
|
self._set_intent(ha_service, intent_state)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
@@ -704,12 +889,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
jf = self._read(self._jellyfin_entity)
|
jf = self._read(self._jellyfin_entity)
|
||||||
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
||||||
|
|
||||||
if self._adb_poller and self._adb_poller.connected and not jf_active:
|
# 1. Protocol key push (fast; works whenever the transport is up)
|
||||||
success = await self._adb_poller.send_media_command(adb_command)
|
protocol_key = PROTOCOL_MEDIA_KEYS.get(ha_service)
|
||||||
|
if (
|
||||||
|
protocol_key
|
||||||
|
and self._manager is not None
|
||||||
|
and await self._manager.send_protocol_key(protocol_key)
|
||||||
|
):
|
||||||
|
if self._intent is not None:
|
||||||
|
self._intent.via_protocol = True
|
||||||
|
self._intent.protocol_key = protocol_key
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. ADB keyevent (socket-independent; exact same commands as before)
|
||||||
|
if self._manager and self._manager.connected and not jf_active:
|
||||||
|
success = await self._manager.send_media_command(adb_command)
|
||||||
if success:
|
if success:
|
||||||
|
# The ADB path served this command — the self-heal retry must
|
||||||
|
# also go over ADB, not back to the remote.
|
||||||
|
if self._intent is not None:
|
||||||
|
self._intent.via_adb = True
|
||||||
|
self._intent.adb_command = adb_command
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fall back to Android TV Remote
|
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
|
||||||
await self._cmd(ha_service)
|
await self._cmd(ha_service)
|
||||||
|
|
||||||
async def async_media_play(self):
|
async def async_media_play(self):
|
||||||
@@ -729,7 +932,197 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
|||||||
|
|
||||||
async def async_bring_to_foreground(self, package: str) -> bool:
|
async def async_bring_to_foreground(self, package: str) -> bool:
|
||||||
"""Bring a specific app to the foreground via ADB."""
|
"""Bring a specific app to the foreground via ADB."""
|
||||||
if not self._adb_poller:
|
if self._manager is None or not self._manager.connected:
|
||||||
_LOGGER.warning("ADB not available for bring_to_foreground")
|
_LOGGER.warning("ADB not available for bring_to_foreground")
|
||||||
return False
|
return False
|
||||||
return await self._adb_poller.bring_to_foreground(package)
|
return await self._manager.bring_to_foreground(package)
|
||||||
|
|
||||||
|
# ── BrowseMedia app directory (P4-2c) ──
|
||||||
|
|
||||||
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
||||||
|
"""Root lists the apps map (or the curated default) → play_media(app)."""
|
||||||
|
from homeassistant.components.media_player import (
|
||||||
|
BrowseMediaSource,
|
||||||
|
MediaClass,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||||
|
children = [
|
||||||
|
BrowseMediaSource(
|
||||||
|
title=name,
|
||||||
|
media_class=MediaClass.APP,
|
||||||
|
media_content_type="app",
|
||||||
|
media_content_id=pkg,
|
||||||
|
can_play=True,
|
||||||
|
can_expand=False,
|
||||||
|
)
|
||||||
|
for pkg, name in app_map.items()
|
||||||
|
]
|
||||||
|
return BrowseMediaSource(
|
||||||
|
title="Apps",
|
||||||
|
media_class=MediaClass.DIRECTORY,
|
||||||
|
media_content_type="apps",
|
||||||
|
media_content_id="apps",
|
||||||
|
can_play=False,
|
||||||
|
can_expand=True,
|
||||||
|
children=children,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── play_media (§7): channel digits, app/url launch ──
|
||||||
|
|
||||||
|
async def async_play_media(self, media_type: str, media_id: str) -> None:
|
||||||
|
"""Channel selection by digit number; app/url launches (C-LAUNCH).
|
||||||
|
|
||||||
|
Channel path replicates HA core androidtv_remote exactly: digits only,
|
||||||
|
configurable inter-digit delay (default 0.1 s), no ENTER — and the
|
||||||
|
padded prior in-flight channel sequence is cancelled first.
|
||||||
|
TEXT entry (P5-2): play_media("text", "...") — protocol IME send_text
|
||||||
|
first; the ADB `input text` fallback tier runs only when the
|
||||||
|
protocol send is unavailable or failed (the Shield may swallow IME
|
||||||
|
text while its on-screen keyboard is up, RESEARCH_IDEAS §2).
|
||||||
|
"""
|
||||||
|
if media_type == "text":
|
||||||
|
if not media_id:
|
||||||
|
_LOGGER.warning("play_media(text): empty text ignored")
|
||||||
|
return
|
||||||
|
if self._manager is not None and await self._manager.send_text_ime(media_id):
|
||||||
|
return
|
||||||
|
if self._manager is not None and await self._manager.send_adb_input_text(media_id):
|
||||||
|
return
|
||||||
|
_LOGGER.warning(
|
||||||
|
"play_media(text): no transport available (protocol down, ADB"
|
||||||
|
" unavailable) — text not sent"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if media_type == "channel":
|
||||||
|
digits = [c for c in media_id if c.isdigit()]
|
||||||
|
if not digits:
|
||||||
|
_LOGGER.warning("play_media(channel): no digits in %r", media_id)
|
||||||
|
return
|
||||||
|
self._last_channel = "".join(digits)
|
||||||
|
self._cancel_inflight_channel()
|
||||||
|
self._channel_task = self._track_task(
|
||||||
|
self._channel_entry_sequence(digits)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if media_type in ("app", "url"):
|
||||||
|
if self._manager is not None and await self._manager.launch_app(media_id):
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
media_type == "app"
|
||||||
|
and self._manager is not None
|
||||||
|
and await self._manager.bring_to_foreground(media_id)
|
||||||
|
):
|
||||||
|
# Fallback: monkey launch of the bare package (no deep links).
|
||||||
|
return
|
||||||
|
_LOGGER.warning(
|
||||||
|
"play_media(%s): could not launch %s — no available transport",
|
||||||
|
media_type,
|
||||||
|
media_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.warning(
|
||||||
|
"play_media: unsupported media type %r (supported: channel/app/url)"
|
||||||
|
" — nothing sent",
|
||||||
|
media_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_inflight_channel(self) -> None:
|
||||||
|
task = self._channel_task
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
self._channel_task = None
|
||||||
|
|
||||||
|
async def _channel_entry_sequence(self, digits: list[str]) -> None:
|
||||||
|
delay = self._entry.options.get(CONF_CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY)
|
||||||
|
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
|
||||||
|
try:
|
||||||
|
for digit in digits:
|
||||||
|
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity
|
||||||
|
served = False
|
||||||
|
if self._manager is not None:
|
||||||
|
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
|
||||||
|
if not served:
|
||||||
|
served = await self._manager.send_digit_key(digit)
|
||||||
|
if not served:
|
||||||
|
await self._cmd(
|
||||||
|
"play_media",
|
||||||
|
media={"media_content_id": digit, "media_content_type": "channel"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if (
|
||||||
|
send_enter
|
||||||
|
and self._manager is not None
|
||||||
|
and not await self._manager.send_protocol_key("KEYCODE_ENTER")
|
||||||
|
):
|
||||||
|
# ENTER fallback chain: protocol key → ADB keyevent 66.
|
||||||
|
# (No legacy-entity tier: the external androidtv_remote has
|
||||||
|
# no ENTER service to delegate to.)
|
||||||
|
await self._manager.send_enter_key()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── P4-2a: the option-gated adb_command service ──
|
||||||
|
# Entities register their manager as a target when their entry enables the
|
||||||
|
# option; the domain service unregisters once the last participant unloads
|
||||||
|
# (core androidtv service posture: documented, opt-in, default off).
|
||||||
|
_ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
|
||||||
|
|
||||||
|
_ADB_COMMAND_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("command"): str,
|
||||||
|
vol.Optional("entity_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_adb_command_service(call) -> None:
|
||||||
|
"""Run a raw ADB shell command on the targeted entry's connection."""
|
||||||
|
command = str(call.data.get("command") or "").strip()
|
||||||
|
if not command:
|
||||||
|
_LOGGER.warning("adb_command: empty command ignored")
|
||||||
|
return
|
||||||
|
entity_id = call.data.get("entity_id")
|
||||||
|
pairs = [
|
||||||
|
(eid, manager)
|
||||||
|
for eid, manager in _ADB_COMMAND_TARGETS.items()
|
||||||
|
if entity_id in (None, eid)
|
||||||
|
]
|
||||||
|
if not pairs:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"adb_command: no participating entry matches %s (is the option "
|
||||||
|
"enabled on the entry's options?)",
|
||||||
|
entity_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for _eid, manager in pairs:
|
||||||
|
poller = manager._poller
|
||||||
|
if poller is None or not manager.connected:
|
||||||
|
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
|
||||||
|
continue
|
||||||
|
result = await poller.shell(command)
|
||||||
|
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_command_service_ensure(hass) -> None:
|
||||||
|
"""Register the domain service once the first participant appears."""
|
||||||
|
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_ADB_COMMAND,
|
||||||
|
_async_adb_command_service,
|
||||||
|
schema=_ADB_COMMAND_SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_command_service_maybe_remove(hass) -> None:
|
||||||
|
"""Unregister when the last participant unloads."""
|
||||||
|
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
|
||||||
|
DOMAIN, SERVICE_ADB_COMMAND
|
||||||
|
):
|
||||||
|
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
|
||||||
|
|
||||||
|
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
|
||||||
|
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
|
||||||
|
|
||||||
|
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
|
||||||
|
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
|
||||||
|
the core port's hold_secs+direction validation quirks are not carried over —
|
||||||
|
long presses are expressed as the START_LONG/END_LONG pair instead;
|
||||||
|
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
|
||||||
|
``hold_secs`` parameter;
|
||||||
|
- ``activity`` = the protocol current-app push (package; name resolution is
|
||||||
|
deferred app-map work);
|
||||||
|
- turn on/off send the POWER key and honor the is_on push (no superfluous
|
||||||
|
toggling when the state already matches).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from homeassistant.components.remote import RemoteEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
|
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
||||||
|
|
||||||
|
_START_PREFIX = "START_LONG:"
|
||||||
|
_END_PREFIX = "END_LONG:"
|
||||||
|
_SHORT_PREFIX = "SHORT:"
|
||||||
|
_POWER_KEY = "KEYCODE_POWER"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_command(command: str) -> tuple[str, str]:
|
||||||
|
"""Key + direction from a command string (default SHORT)."""
|
||||||
|
lowered = command.strip()
|
||||||
|
if lowered.startswith(_START_PREFIX):
|
||||||
|
return lowered[len(_START_PREFIX):], "START_LONG"
|
||||||
|
if lowered.startswith(_END_PREFIX):
|
||||||
|
return lowered[len(_END_PREFIX):], "END_LONG"
|
||||||
|
if lowered.startswith(_SHORT_PREFIX):
|
||||||
|
return lowered[len(_SHORT_PREFIX):], "SHORT"
|
||||||
|
return lowered, "SHORT"
|
||||||
|
|
||||||
|
|
||||||
|
def _key_code_name(command: str) -> str:
|
||||||
|
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
|
||||||
|
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
|
||||||
|
if mapped is not None:
|
||||||
|
return mapped
|
||||||
|
upper = command.upper()
|
||||||
|
if upper.startswith("KEYCODE_"):
|
||||||
|
return upper
|
||||||
|
return upper # unknown names: the library reports/validates them
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Add the protocol-backed remote entity for this entry."""
|
||||||
|
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
|
||||||
|
|
||||||
|
|
||||||
|
class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||||
|
"""Remote entity over the manager's androidtvremote2 transport."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
_attr_should_poll = False
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
self.hass = hass
|
||||||
|
self._entry = entry
|
||||||
|
self._manager: ShieldConnectionManager | None = getattr(
|
||||||
|
entry, "runtime_data", None
|
||||||
|
)
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
|
||||||
|
|
||||||
|
def _manager_of(self) -> ShieldConnectionManager | None:
|
||||||
|
return getattr(self._entry, "runtime_data", None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||||
|
name=self._entry.title,
|
||||||
|
manufacturer="NVIDIA",
|
||||||
|
model="Shield TV",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Unavailable while the transport is active but unpaired/down.
|
||||||
|
|
||||||
|
A passive manager (no protocol transport) keeps the entity available:
|
||||||
|
nothing to transport is not a broken one.
|
||||||
|
"""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return True
|
||||||
|
if not manager.protocol_active:
|
||||||
|
return True
|
||||||
|
return manager.is_available is not False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activity(self) -> str | None:
|
||||||
|
"""Current foreground app reported by the protocol push."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
|
return manager.current_app
|
||||||
|
return None
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _refresh_state(self) -> None:
|
||||||
|
"""Manager store changed — re-evaluate state (activity/available)."""
|
||||||
|
self._manager = self._manager_of()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
manager = self._manager_of()
|
||||||
|
if isinstance(manager, ShieldConnectionManager):
|
||||||
|
self._manager_unregistered = manager.register_listener(
|
||||||
|
self._refresh_state
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
unregistered = getattr(self, "_manager_unregistered", None)
|
||||||
|
if unregistered is not None:
|
||||||
|
unregistered()
|
||||||
|
self._manager_unregistered = None
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs) -> None:
|
||||||
|
"""Wake the device with the POWER key when it reports off."""
|
||||||
|
manager = self._manager_of()
|
||||||
|
if not isinstance(manager, ShieldConnectionManager):
|
||||||
|
return
|
||||||
|
if manager.is_on is not False:
|
||||||
|
# Unknown counts as "maybe on" — no superfluous toggle.
|
||||||
|
return
|
||||||
|
await manager.send_protocol_key(_POWER_KEY, "SHORT")
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs) -> None:
|
||||||
|
"""Sleep the device with the POWER key when it reports on."""
|
||||||
|
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: repeats × (key, or START_LONG/hold/END_LONG)."""
|
||||||
|
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,12 +4,13 @@ import logging
|
|||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
|
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||||
from homeassistant.core import HomeAssistant, callback, Event
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.event import async_track_state_change_event
|
from homeassistant.helpers.event import async_track_state_change_event
|
||||||
|
|
||||||
|
from .connection import ShieldConnectionManager
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ANDROIDTV_ENTITY,
|
CONF_ANDROIDTV_ENTITY,
|
||||||
CONF_JELLYFIN_ENTITY,
|
CONF_JELLYFIN_ENTITY,
|
||||||
@@ -60,43 +61,76 @@ class NvidiaShieldSensorBase(SensorEntity):
|
|||||||
|
|
||||||
|
|
||||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||||
"""Current running app on the Shield."""
|
"""Current running app on the Shield.
|
||||||
|
|
||||||
|
Primary source: the androidtv entity's own state. When that entity is
|
||||||
|
unavailable, falls back to the ShieldConnectionManager's StateStore
|
||||||
|
(most recent visible ADB recent-task app).
|
||||||
|
"""
|
||||||
|
|
||||||
_attr_name = "Current App"
|
_attr_name = "Current App"
|
||||||
_attr_icon = "mdi:application"
|
_attr_icon = "mdi:application"
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
super().__init__(hass, entry)
|
super().__init__(hass, entry)
|
||||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||||
|
|
||||||
|
def _manager(self) -> ShieldConnectionManager | None:
|
||||||
|
manager = getattr(self._entry, "runtime_data", None)
|
||||||
|
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
self._unsub_listeners.append(
|
self._unsub_listeners.append(
|
||||||
async_track_state_change_event(
|
async_track_state_change_event(
|
||||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
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
|
@callback
|
||||||
def _handle_state_change(self, event: Event) -> None:
|
def _handle_state_change(self, event: Event) -> None:
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_store_update(self) -> None:
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> str | None:
|
def native_value(self) -> str | None:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
if not state:
|
if state:
|
||||||
return None
|
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
|
||||||
|
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
||||||
|
# protocol current_app push → ADB mResumedActivity → ADB recents
|
||||||
|
manager = self._manager()
|
||||||
|
if manager is not None:
|
||||||
|
foreground = manager.foreground_app
|
||||||
|
if foreground:
|
||||||
|
return foreground
|
||||||
|
running = manager.running_apps
|
||||||
|
if running:
|
||||||
|
return running[0].get("package")
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict:
|
def extra_state_attributes(self) -> dict:
|
||||||
state = self.hass.states.get(self._androidtv_entity)
|
state = self.hass.states.get(self._androidtv_entity)
|
||||||
if not state:
|
if state:
|
||||||
return {}
|
return {
|
||||||
return {
|
"app_id": state.attributes.get("app_id"),
|
||||||
"app_id": state.attributes.get("app_id"),
|
"app_name": state.attributes.get("app_name"),
|
||||||
"app_name": state.attributes.get("app_name"),
|
}
|
||||||
}
|
manager = self._manager()
|
||||||
|
if manager is not None:
|
||||||
|
running = manager.running_apps
|
||||||
|
if running:
|
||||||
|
return {"app_id": running[0].get("package")}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Device Name",
|
||||||
"androidtv_entity": "Android TV Remote",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver Input",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show App Media Info",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB Host",
|
||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port",
|
||||||
|
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
||||||
|
"protocol_host": "Protocol Host"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||||
@@ -24,21 +26,43 @@
|
|||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"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_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_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_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
||||||
"adb_port": "ADB port number. Default is 5555."
|
"adb_port": "ADB port number. Default is 5555.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
||||||
|
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "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."
|
"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."
|
||||||
|
},
|
||||||
|
"protocol_pair": {
|
||||||
|
"title": "Pair Remote Protocol",
|
||||||
|
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
||||||
|
"data": {
|
||||||
|
"pairing_code": "Pairing code shown on the Shield"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "Re-pair Nvidia Shield",
|
||||||
|
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"adb_failed": "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_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
||||||
|
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
||||||
|
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
||||||
|
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||||
|
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
||||||
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
||||||
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured."
|
"already_configured": "This Shield entity is already configured.",
|
||||||
|
"reauth_successful": "Re-paired successfully.",
|
||||||
|
"reauth_entry_missing": "This entry no longer exists.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -54,17 +78,27 @@
|
|||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show App Media Info",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB Host",
|
||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port",
|
||||||
|
"app_name_map": "Apps name/icon map",
|
||||||
|
"adb_command_enabled": "Enable raw ADB shell service",
|
||||||
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"logcat_events": "Live events push (logcat)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "Which input on the receiver.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"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_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_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_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||||
"adb_port": "ADB port (default 5555)."
|
"adb_port": "ADB port (default 5555).",
|
||||||
|
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
||||||
|
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
||||||
|
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
||||||
|
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
||||||
|
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||||
"data": {
|
"data": {
|
||||||
"name": "Device Name",
|
"name": "Device Name",
|
||||||
"androidtv_entity": "Android TV Remote",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.",
|
||||||
"jellyfin_entity": "Jellyfin Media Player",
|
"jellyfin_entity": "Jellyfin Media Player",
|
||||||
"receiver_entity": "Receiver",
|
"receiver_entity": "Receiver",
|
||||||
"receiver_input": "Receiver Input",
|
"receiver_input": "Receiver Input",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show App Media Info",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB Host",
|
||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port",
|
||||||
|
"protocol_enabled": "Pair with Nvidia Remote Protocol",
|
||||||
|
"protocol_host": "Protocol Host"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||||
@@ -24,21 +26,43 @@
|
|||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"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_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_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_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.",
|
||||||
"adb_port": "ADB port number. Default is 5555."
|
"adb_port": "ADB port number. Default is 5555.",
|
||||||
|
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
|
||||||
|
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adb_pair": {
|
"adb_pair": {
|
||||||
"title": "Pair ADB",
|
"title": "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."
|
"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."
|
||||||
|
},
|
||||||
|
"protocol_pair": {
|
||||||
|
"title": "Pair Remote Protocol",
|
||||||
|
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.",
|
||||||
|
"data": {
|
||||||
|
"pairing_code": "Pairing code shown on the Shield"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reauth_confirm": {
|
||||||
|
"title": "Re-pair Nvidia Shield",
|
||||||
|
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the Shield."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
|
"adb_failed": "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_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.",
|
||||||
|
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.",
|
||||||
|
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.",
|
||||||
|
"protocol_pair_failed": "The Shield rejected that pairing code. Click Submit to try again.",
|
||||||
|
"no_state_source": "No state source selected. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).",
|
||||||
|
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.",
|
||||||
|
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "This Shield entity is already configured."
|
"already_configured": "This Shield entity is already configured.",
|
||||||
|
"reauth_successful": "Re-paired successfully.",
|
||||||
|
"reauth_entry_missing": "This entry no longer exists.",
|
||||||
|
"zeroconf_no_host": "The discovery event carried no usable address."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -54,17 +78,27 @@
|
|||||||
"adb_enabled": "Enable ADB Media Session",
|
"adb_enabled": "Enable ADB Media Session",
|
||||||
"adb_store_app_info": "Show App Media Info",
|
"adb_store_app_info": "Show App Media Info",
|
||||||
"adb_host": "ADB Host",
|
"adb_host": "ADB Host",
|
||||||
"adb_port": "ADB Port"
|
"adb_port": "ADB Port",
|
||||||
|
"app_name_map": "Apps name/icon map",
|
||||||
|
"adb_command_enabled": "Enable raw ADB shell service",
|
||||||
|
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||||
|
"channel_send_enter": "Send ENTER after channel digits",
|
||||||
|
"logcat_events": "Live events push (logcat)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
|
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.",
|
||||||
"receiver_entity": "The receiver this device is connected to.",
|
"receiver_entity": "The receiver this device is connected to.",
|
||||||
"receiver_input": "Which input on the receiver.",
|
"receiver_input": "Which input on the receiver.",
|
||||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
"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_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_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_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||||
"adb_port": "ADB port (default 5555)."
|
"adb_port": "ADB port (default 5555).",
|
||||||
|
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.",
|
||||||
|
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.",
|
||||||
|
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).",
|
||||||
|
"channel_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.",
|
||||||
|
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
|
||||||
|
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
|
||||||
|
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
|
||||||
|
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
|
||||||
|
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
|
||||||
|
|
||||||
|
The migration path: point the automations/scripts at the integration's DAG
|
||||||
|
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
|
||||||
|
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
|
||||||
|
needed for the MVP feature set; the appendix exists so retirement can be
|
||||||
|
staged per household.
|
||||||
|
|
||||||
|
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
|
||||||
|
publishes no MediaSession on Shield Android 11. Its visibility comes from the
|
||||||
|
foreground tier (tier 3.5) with the audio-playback hint and the
|
||||||
|
last-known-state cache — never from a session, never resurrected past the
|
||||||
|
state TTL.
|
||||||
+5
-8
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ha-nvidia-shield"
|
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"
|
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -14,14 +14,11 @@ dev = [
|
|||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"ruff>=0.4",
|
"ruff>=0.4",
|
||||||
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
|
"androidtvremote2>=0.2.0",
|
||||||
# for HACS installs; used editable from a sibling checkout for development.
|
|
||||||
# Planned migration to a proper PyPI package.
|
|
||||||
"ha-adb-common",
|
|
||||||
]
|
]
|
||||||
|
# The shared ADB helpers are vendored at
|
||||||
[tool.uv.sources]
|
# custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
|
||||||
ha-adb-common = { path = "../ha-adb-common", editable = true }
|
# this fork, so no extra dev dependency (and no unavailable sibling checkout).
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""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,235 @@
|
|||||||
|
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
|
||||||
|
|
||||||
|
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
|
||||||
|
enum values the integration actually uses, and the event helpers. Tests load
|
||||||
|
their entity module through the synthetic ``nsh`` package AFTER calling
|
||||||
|
:func:`install` (and the per-file fixture pops everything at teardown).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
ENTITIES_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.const",
|
||||||
|
"homeassistant.components",
|
||||||
|
"homeassistant.components.media_player",
|
||||||
|
"homeassistant.components.remote",
|
||||||
|
"homeassistant.components.button",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
"homeassistant.helpers.event",
|
||||||
|
"homeassistant.helpers.entity",
|
||||||
|
"homeassistant.helpers.entity_platform",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.power",
|
||||||
|
"nsh.jellyfin",
|
||||||
|
"nsh.media_player",
|
||||||
|
"nsh.diagnostics",
|
||||||
|
"nsh.remote",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPlayerState(enum.StrEnum):
|
||||||
|
OFF = "off"
|
||||||
|
ON = "on"
|
||||||
|
IDLE = "idle"
|
||||||
|
PLAYING = "playing"
|
||||||
|
PAUSED = "paused"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class MediaClass:
|
||||||
|
APP = "app"
|
||||||
|
DIRECTORY = "directory"
|
||||||
|
|
||||||
|
|
||||||
|
class BrowseMediaSource:
|
||||||
|
"""HA BrowseMedia shape used by the app directory tests."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title,
|
||||||
|
media_class,
|
||||||
|
media_content_type,
|
||||||
|
media_content_id,
|
||||||
|
can_play,
|
||||||
|
can_expand,
|
||||||
|
children=None,
|
||||||
|
thumbnail=None,
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self.media_class = media_class
|
||||||
|
self.media_content_type = media_content_type
|
||||||
|
self.media_content_id = media_content_id
|
||||||
|
self.can_play = can_play
|
||||||
|
self.can_expand = can_expand
|
||||||
|
self.children = children or []
|
||||||
|
self.thumbnail = thumbnail
|
||||||
|
|
||||||
|
|
||||||
|
class MediaEntityManager:
|
||||||
|
"""trivial stand-in for the HA platform internals we don't exercise."""
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPlayerEntity:
|
||||||
|
"""Base entity: attributes only; state writing is a no-op."""
|
||||||
|
|
||||||
|
def async_write_ha_state(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEntity:
|
||||||
|
def async_write_ha_state(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install() -> None:
|
||||||
|
"""Register the stub modules (idempotent)."""
|
||||||
|
if "homeassistant.components.media_player" in sys.modules:
|
||||||
|
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")
|
||||||
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
_config_flow_base = type(
|
||||||
|
"ConfigFlow",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"__init_subclass__": classmethod(
|
||||||
|
lambda cls, domain=None, **kwargs: None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
config_entries.ConfigFlow = _config_flow_base
|
||||||
|
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
||||||
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
core.CALLBACK_TYPE = object
|
||||||
|
core.Event = object
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
ha_const.STATE_PLAYING = "playing"
|
||||||
|
ha_const.STATE_PAUSED = "paused"
|
||||||
|
ha_const.Platform = types.SimpleNamespace(
|
||||||
|
MEDIA_PLAYER="media_player",
|
||||||
|
SENSOR="sensor",
|
||||||
|
REMOTE="remote",
|
||||||
|
BUTTON="button",
|
||||||
|
)
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
components = types.ModuleType("homeassistant.components")
|
||||||
|
components.__path__ = []
|
||||||
|
sys.modules["homeassistant.components"] = components
|
||||||
|
|
||||||
|
mp = types.ModuleType("homeassistant.components.media_player")
|
||||||
|
mp.MediaPlayerEntity = MediaPlayerEntity
|
||||||
|
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
|
||||||
|
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
|
||||||
|
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor
|
||||||
|
mp.BrowseMediaSource = BrowseMediaSource
|
||||||
|
mp.MediaClass = MediaClass
|
||||||
|
mp.MediaPlayerState = MediaPlayerState
|
||||||
|
sys.modules["homeassistant.components.media_player"] = mp
|
||||||
|
|
||||||
|
remote = types.ModuleType("homeassistant.components.remote")
|
||||||
|
remote.RemoteEntity = RemoteEntity
|
||||||
|
sys.modules["homeassistant.components.remote"] = remote
|
||||||
|
|
||||||
|
button = types.ModuleType("homeassistant.components.button")
|
||||||
|
button.ButtonEntity = type("ButtonEntity", (), {})
|
||||||
|
sys.modules["homeassistant.components.button"] = button
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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=lambda *a, **k: None,
|
||||||
|
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
|
||||||
|
helpers.entity = entity
|
||||||
|
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||||
|
entity_platform.AddEntitiesCallback = object
|
||||||
|
helpers.entity_platform = entity_platform
|
||||||
|
sys.modules["homeassistant.helpers.event"] = event
|
||||||
|
sys.modules["homeassistant.helpers.entity"] = entity
|
||||||
|
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||||
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
|
||||||
|
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", "media_player", "remote", "diagnostics"):
|
||||||
|
if f"nsh.{name}" not in sys.modules:
|
||||||
|
_load_module(f"nsh.{name}", _SHIELD_DIR / f"{name}.py")
|
||||||
|
return {
|
||||||
|
name: sys.modules[f"nsh.{name}"]
|
||||||
|
for name in ("power", "jellyfin", "media_player", "remote", "diagnostics")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHass:
|
||||||
|
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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,965 @@
|
|||||||
|
"""t10 tests: command routing, play_media channel port, remote entity, volume.
|
||||||
|
|
||||||
|
Concrete fakes expose the ordering precisely: protocol → ADB keyevent →
|
||||||
|
legacy external entity, plus the §7 channel digit sequence (protocol digits,
|
||||||
|
~0.1 s apart, no ENTER, new sequence cancels in-flight). A scripted media
|
||||||
|
layer marks which tier served each command.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
import pytest
|
||||||
|
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
||||||
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
from nsh.const import TIVIMATE_PACKAGE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_entity_stubs():
|
||||||
|
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in ENTITIES_STUB_NAMES}
|
||||||
|
install()
|
||||||
|
yield load_entity_modules()
|
||||||
|
for name in ENTITIES_STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProtocolClient:
|
||||||
|
"""Records key/launch calls; optional scripted failures."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.key_calls: list[tuple[str, str]] = []
|
||||||
|
self.launch_calls: list[str] = []
|
||||||
|
self.fail_keys: set[str] = set()
|
||||||
|
self.fail_all = False
|
||||||
|
|
||||||
|
def send_key_command(self, key, direction="SHORT"):
|
||||||
|
self.key_calls.append((key, direction))
|
||||||
|
if self.fail_all or key.upper() in self.fail_keys:
|
||||||
|
raise ConnectionError("protocol transport down")
|
||||||
|
|
||||||
|
def send_launch_app_command(self, app_link_or_app_id: str) -> None:
|
||||||
|
self.launch_calls.append(app_link_or_app_id)
|
||||||
|
if self.fail_all:
|
||||||
|
raise ConnectionError("transport down")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbPoller:
|
||||||
|
"""Minimal ADB surface for keyevent and digit fallback paths."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.media_calls: list[str] = []
|
||||||
|
self.digits: list[str] = []
|
||||||
|
self.enter_calls: list[bool] = []
|
||||||
|
self.results: dict[str, bool] = {}
|
||||||
|
self.digit_result = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send_media_command(self, command: str) -> bool:
|
||||||
|
self.media_calls.append(command)
|
||||||
|
return self.results.get(command, True)
|
||||||
|
|
||||||
|
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||||
|
self.digits.append(digit)
|
||||||
|
return self.digit_result
|
||||||
|
|
||||||
|
async def send_enter_keyevent(self) -> bool:
|
||||||
|
self.enter_calls.append(True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def make_manager(store=None, poller=None, protocol=None):
|
||||||
|
return ShieldConnectionManager(
|
||||||
|
hass=None,
|
||||||
|
entry=None,
|
||||||
|
poller=poller or FakeAdbPoller(),
|
||||||
|
protocol=protocol or FakeProtocolClient(),
|
||||||
|
store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="modules")
|
||||||
|
def load_modules_fixture():
|
||||||
|
return load_entity_modules()
|
||||||
|
|
||||||
|
|
||||||
|
def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||||
|
"""A pre-initialised media_player entity bound to the tested manager."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mp_module = modules["media_player"]
|
||||||
|
if manager is None:
|
||||||
|
manager = make_manager()
|
||||||
|
entry = SimpleNamespace(options={}, data={}, title="mdarcy")
|
||||||
|
manager.entry = entry
|
||||||
|
|
||||||
|
def _set_option(key, value):
|
||||||
|
entry.options[key] = value
|
||||||
|
|
||||||
|
entry.set_option = _set_option
|
||||||
|
player = mp_module.NvidiaShieldMediaPlayer.__new__(mp_module.NvidiaShieldMediaPlayer)
|
||||||
|
player.hass = None
|
||||||
|
player._entry = entry
|
||||||
|
player._manager = manager
|
||||||
|
player._intent = None
|
||||||
|
player._last_channel = None
|
||||||
|
player.hass = entity_stublib.FakeHass()
|
||||||
|
player._entity_tasks = set()
|
||||||
|
player._channel_task = None
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
player._read = lambda eid: None
|
||||||
|
|
||||||
|
served = []
|
||||||
|
|
||||||
|
async def _cmd(service, **kwargs):
|
||||||
|
served.append(("legacy", service))
|
||||||
|
return None
|
||||||
|
|
||||||
|
player._cmd = _cmd
|
||||||
|
|
||||||
|
captured_intents = []
|
||||||
|
|
||||||
|
def _set_intent(service, target):
|
||||||
|
intent = SimpleNamespace(
|
||||||
|
target_state=getattr(target, "value", target),
|
||||||
|
command=service,
|
||||||
|
created_at=0.0,
|
||||||
|
prior_state=None,
|
||||||
|
retry_count=0,
|
||||||
|
via_adb=False,
|
||||||
|
via_protocol=False,
|
||||||
|
adb_command=None,
|
||||||
|
protocol_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _unsub():
|
||||||
|
return None
|
||||||
|
|
||||||
|
player._intent = intent
|
||||||
|
captured_intents.append(intent)
|
||||||
|
return None
|
||||||
|
|
||||||
|
player._set_intent = _set_intent
|
||||||
|
return player, served, captured_intents
|
||||||
|
|
||||||
|
|
||||||
|
class TestMediaCommandRouting:
|
||||||
|
async def test_protocol_serves_media_command(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
player._intent = None
|
||||||
|
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
|
||||||
|
await player._media_cmd("media_play", "play", "playing")
|
||||||
|
|
||||||
|
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_intent_records_protocol_path_for_retries(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
player._set_intent("media_play", type("S", (), {"value": "playing"})())
|
||||||
|
player.async_write_ha_state = lambda: None
|
||||||
|
await player._media_cmd("media_play", "play", "playing")
|
||||||
|
# retry metadata stamped: the protocol path is recorded on the intent
|
||||||
|
assert player._intent is not None
|
||||||
|
assert player._intent.via_protocol is True
|
||||||
|
assert player._intent.protocol_key == "KEYCODE_MEDIA_PLAY"
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelDigits:
|
||||||
|
async def test_digit_sequence_digit_part_delivery(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
|
||||||
|
await player._channel_entry_sequence(["4", "0", "9", "6"])
|
||||||
|
|
||||||
|
# protocol digits: KEYCODE_{digit} SHORT each
|
||||||
|
assert protocol.key_calls == [
|
||||||
|
("KEYCODE_4", "SHORT"),
|
||||||
|
("KEYCODE_0", "SHORT"),
|
||||||
|
("KEYCODE_9", "SHORT"),
|
||||||
|
("KEYCODE_6", "SHORT"),
|
||||||
|
]
|
||||||
|
assert served == [] # never fell through to legacy
|
||||||
|
|
||||||
|
async def test_new_channel_cancels_inflight_sequence(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
|
||||||
|
# start a long sequence (real path), then a new play_media replaces it
|
||||||
|
first_task = player._track_task(player._channel_entry_sequence(["1", "2", "3"]))
|
||||||
|
player._channel_task = first_task # as async_play_media tracks it
|
||||||
|
|
||||||
|
await player.async_play_media("channel", "98")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
assert first_task.cancelled()
|
||||||
|
assert "KEYCODE_2" not in [k for k, _d in protocol.key_calls]
|
||||||
|
player._cancel_inflight_channel() # test cleanup
|
||||||
|
|
||||||
|
async def test_fallback_to_adb_digits_when_protocol_down(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
protocol.fail_all = True
|
||||||
|
|
||||||
|
await player._channel_entry_sequence(["5"])
|
||||||
|
|
||||||
|
assert protocol.key_calls == [("KEYCODE_5", "SHORT")]
|
||||||
|
assert player._manager._poller.digits == ["5"] # ADB digit keyevent served
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_optional_enter_falls_back_to_adb_keyevent(self, modules):
|
||||||
|
player, served, _i = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
poller = player._manager._poller
|
||||||
|
protocol.fail_keys.add("KEYCODE_ENTER")
|
||||||
|
protocol.key_calls.clear()
|
||||||
|
|
||||||
|
player._entry.options["channel_send_enter"] = True # §7 optional ENTER on
|
||||||
|
|
||||||
|
await player._channel_entry_sequence(["4", "1"]) # digits fine, ENTER fails
|
||||||
|
|
||||||
|
# digits served by protocol; ENTER retried over ADB keyevent 66
|
||||||
|
assert ("KEYCODE_4", "SHORT") in protocol.key_calls
|
||||||
|
assert poller.digits == [] # digits were fine over the protocol
|
||||||
|
assert poller.enter_calls == [True]
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_legacy_fallback_when_both_transports_down(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
protocol.fail_all = True
|
||||||
|
player._manager._poller.digit_result = False
|
||||||
|
|
||||||
|
await player._channel_entry_sequence(["7"])
|
||||||
|
|
||||||
|
assert served == [("legacy", "play_media")] # transitional path used
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppAndUrls:
|
||||||
|
async def test_app_launch_calls_protocol(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
|
||||||
|
await player.async_play_media("app", "com.plex.hdx")
|
||||||
|
|
||||||
|
assert protocol.launch_calls == ["com.plex.hdx"] # C-LAUNCH auto-prefix
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
async def test_url_launch_passes_through_unchanged(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
|
||||||
|
await player.async_play_media("url", "plex://open")
|
||||||
|
|
||||||
|
assert protocol.launch_calls == ["plex://open"]
|
||||||
|
|
||||||
|
async def test_unknown_media_type_no_send(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
|
||||||
|
await player.async_play_media("spotify_uri", "spotify:track:x")
|
||||||
|
|
||||||
|
assert protocol.launch_calls == []
|
||||||
|
assert protocol.key_calls == []
|
||||||
|
assert served == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteEntity:
|
||||||
|
async def test_split_command_prefixes(self, modules):
|
||||||
|
module = modules["remote"]
|
||||||
|
assert module._split_command("SHORT:home") == ("home", "SHORT")
|
||||||
|
assert module._split_command("START_LONG:home") == ("home", "START_LONG")
|
||||||
|
assert module._split_command("END_LONG:home") == ("home", "END_LONG")
|
||||||
|
assert module._split_command("home") == ("home", "SHORT")
|
||||||
|
|
||||||
|
async def test_key_code_name_mapping(self, modules):
|
||||||
|
module = modules["remote"]
|
||||||
|
assert module._key_code_name("home") == "KEYCODE_HOME"
|
||||||
|
assert module._key_code_name("KEYCODE_HOME") == "KEYCODE_HOME"
|
||||||
|
assert module._key_code_name("KEYCODE_HDMI_1") == "KEYCODE_HDMI_1"
|
||||||
|
assert module._key_code_name("play") == "KEYCODE_MEDIA_PLAY"
|
||||||
|
|
||||||
|
async def test_send_command_hits_protocol(self, modules):
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
protocol = entity._manager_of()._protocol
|
||||||
|
|
||||||
|
await entity.async_send_command(["HOME"])
|
||||||
|
|
||||||
|
assert protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
|
||||||
|
|
||||||
|
async def test_send_command_honors_hold_secs(self, modules):
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
protocol = entity._manager_of()._protocol
|
||||||
|
|
||||||
|
await entity.async_send_command(["home"], hold_secs=0.05)
|
||||||
|
|
||||||
|
assert protocol.key_calls == [
|
||||||
|
("KEYCODE_HOME", "START_LONG"),
|
||||||
|
("KEYCODE_HOME", "END_LONG"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_send_command_honors_num_repeats(self, modules):
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
protocol = entity._manager_of()._protocol
|
||||||
|
|
||||||
|
await entity.async_send_command(["home"], num_repeats=3)
|
||||||
|
|
||||||
|
assert len(protocol.key_calls) == 3
|
||||||
|
|
||||||
|
async def test_turn_on_off_send_power_key_respecting_is_on(self, modules):
|
||||||
|
entity = _remote_entity(modules)
|
||||||
|
manager = entity._manager_of()
|
||||||
|
protocol = manager._protocol
|
||||||
|
|
||||||
|
# push "already off" — turn_on should send POWER, turn_off should not
|
||||||
|
manager._on_is_on(False)
|
||||||
|
await entity.async_turn_on()
|
||||||
|
assert protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||||
|
|
||||||
|
protocol.key_calls.clear()
|
||||||
|
await entity.async_turn_off()
|
||||||
|
assert protocol.key_calls == []
|
||||||
|
|
||||||
|
# push "on" — turn_off should send POWER, turn_on should not
|
||||||
|
manager._on_is_on(True)
|
||||||
|
protocol.key_calls.clear()
|
||||||
|
await entity.async_turn_off()
|
||||||
|
assert protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||||
|
protocol.key_calls.clear()
|
||||||
|
await entity.async_turn_on()
|
||||||
|
assert protocol.key_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppFallback:
|
||||||
|
async def test_app_id_prefers_the_androidtv_entity(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._androidtv_entity = "media_player.atv"
|
||||||
|
from types import SimpleNamespace as FakeState
|
||||||
|
|
||||||
|
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
|
||||||
|
states = FakeState(states=FakeState(
|
||||||
|
get=lambda eid: state if eid == "media_player.atv" else None
|
||||||
|
))
|
||||||
|
player.hass = states
|
||||||
|
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||||
|
|
||||||
|
assert player.app_id == "com.plex.hdx"
|
||||||
|
assert player.app_name == "Plex"
|
||||||
|
|
||||||
|
async def test_app_falls_back_to_protocol_current_app(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._androidtv_entity = "" # standalone setup (P3-1)
|
||||||
|
player._manager._on_current_app("com.netflix.ninja")
|
||||||
|
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||||
|
|
||||||
|
assert player.app_id == "com.netflix.ninja"
|
||||||
|
assert player.app_name == "com.netflix.ninja" # package is truth; map later
|
||||||
|
|
||||||
|
async def test_app_unavailable_entity_falls_back(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._androidtv_entity = "media_player.atv"
|
||||||
|
from types import SimpleNamespace as FakeState
|
||||||
|
|
||||||
|
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
|
||||||
|
player._read = lambda eid: None
|
||||||
|
player._manager._on_current_app("com.google.android.youtube.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.app_id == "com.google.android.youtube.tv"
|
||||||
|
|
||||||
|
|
||||||
|
def _ladder_player(modules, foreground=None, audio=None):
|
||||||
|
"""Standalone player with a foreground (and optional audio hint) set."""
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager.store.set("awake", True)
|
||||||
|
if foreground:
|
||||||
|
manager.store.set("foreground", foreground)
|
||||||
|
if audio:
|
||||||
|
manager.store.set("audio_playback", audio)
|
||||||
|
player._androidtv_entity = ""
|
||||||
|
player._read = lambda eid: None
|
||||||
|
return player, manager
|
||||||
|
|
||||||
|
|
||||||
|
class TestForegroundLadderTier:
|
||||||
|
"""P3-3a: the ladder branch between the media-session tier and ON."""
|
||||||
|
|
||||||
|
def _player(self, modules, sessions=None, foreground=None, audio=None):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager.store.set("awake", True)
|
||||||
|
if sessions:
|
||||||
|
manager.store.set("sessions", sessions)
|
||||||
|
if foreground:
|
||||||
|
manager.store.set("foreground", foreground)
|
||||||
|
if audio:
|
||||||
|
manager.store.set("audio_playback", audio)
|
||||||
|
player._androidtv_entity = ""
|
||||||
|
player._read = lambda eid: None
|
||||||
|
return player, manager
|
||||||
|
|
||||||
|
async def test_session_app_serves_playing_via_session_tier(self, modules):
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo
|
||||||
|
|
||||||
|
sessions = [
|
||||||
|
MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")
|
||||||
|
]
|
||||||
|
player, manager = self._player(
|
||||||
|
modules, sessions=sessions, foreground="com.netflix.ninja"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert player.state.value == "playing" # session tier, not the fg tier
|
||||||
|
np = player._get_adb_now_playing()
|
||||||
|
assert np["source"] == "media_session"
|
||||||
|
assert np["title"] == "Movie"
|
||||||
|
|
||||||
|
async def test_no_session_app_with_audio_hint(self, modules):
|
||||||
|
player, _m = self._player(
|
||||||
|
modules,
|
||||||
|
foreground="ar.tvplayer.tv",
|
||||||
|
audio=["ar.tvplayer.tv"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# TiviMate publishes no session (R2) — fg tier keeps the device ON
|
||||||
|
assert player.state.value == "on"
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
assert np == {
|
||||||
|
"state": "idle",
|
||||||
|
"title": "Media recently active",
|
||||||
|
"source": "app",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_no_session_app_without_hint_returns_none(self, modules):
|
||||||
|
player, _m = self._player(modules, foreground="com.spocky.projengmenu")
|
||||||
|
|
||||||
|
assert player.state.value == "on" # launcher foreground → still ON
|
||||||
|
assert player._get_app_now_playing() is None
|
||||||
|
|
||||||
|
async def test_last_known_title_beats_audio_hint(self, modules):
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo
|
||||||
|
|
||||||
|
player, manager = self._player(
|
||||||
|
modules, foreground="ar.tvplayer.tv", audio=["ar.tvplayer.tv"]
|
||||||
|
)
|
||||||
|
manager._clock = lambda: 0.0 # stamp 0.0, clock 0.0 → inside TTL
|
||||||
|
info = MediaSessionInfo(package="ar.tvplayer.tv", playing=True, title="Sky News")
|
||||||
|
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
|
||||||
|
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
|
||||||
|
|
||||||
|
async def test_ttl_resurrect_guard_in_the_tier(self, modules):
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo
|
||||||
|
|
||||||
|
now = 1000.0
|
||||||
|
player, manager = self._player(modules, foreground="ar.tvplayer.tv")
|
||||||
|
player._last_channel = "101"
|
||||||
|
|
||||||
|
info = MediaSessionInfo(package="ar.tvplayer.tv", playing=True, title="Sky News")
|
||||||
|
manager._clock = lambda: now # the closure reads the live `now` cell
|
||||||
|
manager._last_known["ar.tvplayer.tv"] = (info, now)
|
||||||
|
|
||||||
|
# inside TTL → last-known title served
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
|
||||||
|
|
||||||
|
# past STATE_STORE_TTL → 'playing' must NOT be resurrected;
|
||||||
|
# the tier falls back to the last channel instead
|
||||||
|
now = now + 61.0
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
assert np == {"state": "idle", "title": "101", "source": "app"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelLeakGuard:
|
||||||
|
"""F1: the channel number never leaks to a non-channel foreground app."""
|
||||||
|
|
||||||
|
async def test_launcher_never_shows_the_channel_number(self, modules):
|
||||||
|
player, _m = _ladder_player(
|
||||||
|
modules, foreground="com.spocky.projengmenu"
|
||||||
|
)
|
||||||
|
player._last_channel = "101" # entered in TiviMate earlier
|
||||||
|
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
|
||||||
|
assert np is None # the leak is gone
|
||||||
|
|
||||||
|
async def test_channel_app_still_gets_the_channel_title(self, modules):
|
||||||
|
player, _m = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||||
|
|
||||||
|
player._last_channel = "101"
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
|
||||||
|
assert np == {"state": "idle", "title": "101", "source": "app"}
|
||||||
|
|
||||||
|
async def test_audio_member_can_carry_the_channel_title(self, modules):
|
||||||
|
player, manager = _ladder_player(
|
||||||
|
modules, foreground="ar.tvplayer.tv", audio=["ar.tvplayer.tv"]
|
||||||
|
)
|
||||||
|
|
||||||
|
player._last_channel = "205"
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
|
||||||
|
assert np["title"] == "205"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivacyOrdering:
|
||||||
|
"""F3: privacy mode outranks the channel fallback — identity stays the app."""
|
||||||
|
|
||||||
|
async def test_channel_number_never_leaks_in_privacy_mode(self, modules):
|
||||||
|
player, manager = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||||
|
player._entry.options["adb_store_app_info"] = False # privacy on
|
||||||
|
|
||||||
|
player._last_channel = "101"
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
|
||||||
|
assert np["title"] == TIVIMATE_PACKAGE # app identity, not the channel
|
||||||
|
assert np["title"] != "101"
|
||||||
|
|
||||||
|
async def test_channel_number_never_leaks_in_privacy_mode_with_last_known(self, modules):
|
||||||
|
from nsh.ha_adb_common import MediaSessionInfo
|
||||||
|
|
||||||
|
player, manager = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||||
|
player._entry.options["adb_store_app_info"] = False
|
||||||
|
manager._clock = lambda: 0.0
|
||||||
|
info = MediaSessionInfo(
|
||||||
|
package=TIVIMATE_PACKAGE, playing=True, title="Sky News"
|
||||||
|
)
|
||||||
|
manager._last_known[TIVIMATE_PACKAGE] = (info, 0.0)
|
||||||
|
|
||||||
|
player._last_channel = "101"
|
||||||
|
np = player._get_app_now_playing()
|
||||||
|
|
||||||
|
assert np["state"] == "playing" # playback truth survives privacy
|
||||||
|
assert np["title"] == TIVIMATE_PACKAGE # but identity stays the app
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolumeAndPowerLadder:
|
||||||
|
async def test_volume_readout_from_protocol_push(self, modules):
|
||||||
|
player, served, _intents = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager._on_volume_info({"level": 7, "max": 15, "muted": True})
|
||||||
|
|
||||||
|
assert player.volume_level == pytest.approx(7 / 15)
|
||||||
|
assert player.is_volume_muted is True
|
||||||
|
|
||||||
|
async def test_volume_set_is_not_claimed(self, modules):
|
||||||
|
"""C-VOL: VOLUME_SET stays out of supported features (readout only)."""
|
||||||
|
from nsh.media_player import _BASE_FEATURES # noqa: PLC0415
|
||||||
|
|
||||||
|
assert not (_BASE_FEATURES & (1 << 5)) # VOLUME_SET bit off
|
||||||
|
|
||||||
|
async def test_is_on_ladder_protocol_first(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
|
||||||
|
# no protocol push, no ADB wake data: fall to the plug/other logic
|
||||||
|
assert manager.is_on is None
|
||||||
|
|
||||||
|
manager._on_is_on(True)
|
||||||
|
assert player._is_power_on() is True
|
||||||
|
|
||||||
|
manager._on_is_on(False)
|
||||||
|
assert player._is_power_on() is False
|
||||||
|
|
||||||
|
async def test_adb_wake_used_when_protocol_silent(self, modules):
|
||||||
|
player, _served, _intents = make_player(modules)
|
||||||
|
manager = player._manager
|
||||||
|
manager.store.set("awake", False)
|
||||||
|
# protocol silent (is_on None) → ADB wake is the decision
|
||||||
|
assert manager.is_on is None
|
||||||
|
assert player._is_power_on() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── shared helpers ──
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_entity(modules):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
remote_module = modules["remote"]
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
options={},
|
||||||
|
data={},
|
||||||
|
title="mdarcy",
|
||||||
|
entry_id="e1",
|
||||||
|
)
|
||||||
|
manager = make_manager()
|
||||||
|
entry.runtime_data = manager
|
||||||
|
entity = remote_module.NvidiaShieldRemoteEntity.__new__(
|
||||||
|
remote_module.NvidiaShieldRemoteEntity
|
||||||
|
)
|
||||||
|
entity.hass = None
|
||||||
|
entity._entry = entry
|
||||||
|
entity._manager = manager
|
||||||
|
entity._attr_unique_id = None
|
||||||
|
entity._manager_unregistered = None
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_async(*args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeServices:
|
||||||
|
"""hass.services stand-in recording registrations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.registered: dict[tuple, object] = {}
|
||||||
|
self.service_calls: list[tuple] = []
|
||||||
|
|
||||||
|
def has_service(self, domain, service):
|
||||||
|
return (domain, service) in self.registered
|
||||||
|
|
||||||
|
def async_register(self, domain, service, func, schema=None):
|
||||||
|
self.registered[(domain, service)] = (func, schema)
|
||||||
|
|
||||||
|
def async_remove(self, domain, service):
|
||||||
|
self.registered.pop((domain, service), None)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceHarness:
|
||||||
|
"""FakeHass extension: `.services` + direct service invocation."""
|
||||||
|
|
||||||
|
def attach(self, player):
|
||||||
|
player.hass.services = FakeServices()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbCommandService:
|
||||||
|
"""P4-2a: option-gated raw shell service on the entry."""
|
||||||
|
|
||||||
|
def _participant(self, modules, enabled, connected=True):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._entry.options["adb_command_enabled"] = enabled
|
||||||
|
if not connected:
|
||||||
|
player._manager._poller = None
|
||||||
|
player.entity_id = "media_player.shield_test"
|
||||||
|
player._attr_unique_id = "uid_shield"
|
||||||
|
kind = entity_stublib.FakeHass()
|
||||||
|
player.hass = kind
|
||||||
|
kind.services = FakeServices()
|
||||||
|
return player, kind.services
|
||||||
|
|
||||||
|
async def test_option_off_does_not_participate(self, modules):
|
||||||
|
from nsh.media_player import _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
|
before = dict(_ADB_COMMAND_TARGETS)
|
||||||
|
player, _services = self._participant(modules, enabled=False)
|
||||||
|
# the add hook consults the entry option — off → no registration
|
||||||
|
if player._entry.options.get("adb_command_enabled", False):
|
||||||
|
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
|
||||||
|
assert before == _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
|
async def test_option_on_registers_and_runs(self, modules):
|
||||||
|
player, services = self._participant(modules, enabled=True)
|
||||||
|
|
||||||
|
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
|
||||||
|
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
||||||
|
|
||||||
|
class Call:
|
||||||
|
data = {"command": "dumpsys power | head -5"}
|
||||||
|
|
||||||
|
shell_calls = []
|
||||||
|
|
||||||
|
async def _shell(cmd):
|
||||||
|
shell_calls.append(cmd)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
player._manager._poller.shell = _shell
|
||||||
|
await _async_adb_command_service(Call())
|
||||||
|
|
||||||
|
assert shell_calls == ["dumpsys power | head -5"]
|
||||||
|
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
||||||
|
|
||||||
|
async def test_rejects_when_adb_unavailable(self, modules):
|
||||||
|
player, _s = self._participant(modules, enabled=True, connected=False)
|
||||||
|
|
||||||
|
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
|
||||||
|
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
||||||
|
|
||||||
|
class Call:
|
||||||
|
data = {"command": "echo hi"}
|
||||||
|
|
||||||
|
# no poller → the command is not sent and nothing raises
|
||||||
|
player._manager._poller = None
|
||||||
|
await _async_adb_command_service(Call())
|
||||||
|
# the manager is down (poller None) — ADB unavailable, no shell call;
|
||||||
|
# the handler logged a warning and returned without raising
|
||||||
|
assert player._manager._poller is None
|
||||||
|
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
|
||||||
|
|
||||||
|
async def test_unregister_on_last_participant_unload(self, modules):
|
||||||
|
from nsh.media_player import (
|
||||||
|
_ADB_COMMAND_TARGETS,
|
||||||
|
_adb_command_service_ensure,
|
||||||
|
_adb_command_service_maybe_remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
mods = load_entity_modules()
|
||||||
|
player, _s, _i = make_player(mods)
|
||||||
|
player._entry.options["adb_command_enabled"] = True
|
||||||
|
player.entity_id = "media_player.shield_test"
|
||||||
|
player.hass.services = FakeServices()
|
||||||
|
|
||||||
|
_adb_command_service_ensure(player.hass)
|
||||||
|
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
|
||||||
|
assert player.hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
_ADB_COMMAND_TARGETS.pop("media_player.shield_test")
|
||||||
|
_adb_command_service_maybe_remove(player.hass)
|
||||||
|
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelDigitDelayConsumed:
|
||||||
|
"""t26 F3: a non-default digit delay reaches the sequence's sleep."""
|
||||||
|
|
||||||
|
async def test_non_default_delay_reaches_the_sequence_sleep(self, modules):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
player, _s = _ladder_player(modules)
|
||||||
|
player._entry.options["channel_digit_delay"] = 0.3 # non-default
|
||||||
|
|
||||||
|
sleeps: list[float] = []
|
||||||
|
real_sleep = asyncio.sleep
|
||||||
|
|
||||||
|
mp_module = sys.modules["nsh.media_player"]
|
||||||
|
real_module_sleep = mp_module.asyncio.sleep
|
||||||
|
|
||||||
|
async def spying_sleep(delay, *args):
|
||||||
|
sleeps.append(delay)
|
||||||
|
await real_sleep(0)
|
||||||
|
|
||||||
|
mp_module.asyncio.sleep = spying_sleep
|
||||||
|
try:
|
||||||
|
await player.async_play_media("channel", "123")
|
||||||
|
if player._channel_task is not None:
|
||||||
|
await player._channel_task # the sequence runs to completion
|
||||||
|
finally:
|
||||||
|
mp_module.asyncio.sleep = real_module_sleep
|
||||||
|
|
||||||
|
# three digits → three sleeps, each with the option's 0.3 s
|
||||||
|
assert sleeps == [0.3, 0.3, 0.3]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextEntry:
|
||||||
|
"""P5-2: text entry — protocol IME first, ADB input-text fallback."""
|
||||||
|
|
||||||
|
def _player_with_texts(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
protocol.text_calls = []
|
||||||
|
protocol.send_text = lambda t: protocol.text_calls.append(t) or True
|
||||||
|
shell_texts = []
|
||||||
|
|
||||||
|
async def _shell(cmd):
|
||||||
|
shell_texts.append(cmd)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
player._manager._poller.shell = _shell
|
||||||
|
return player, protocol, shell_texts
|
||||||
|
|
||||||
|
async def test_protocol_ime_sends_first(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
await player.async_play_media("text", "hello world")
|
||||||
|
|
||||||
|
assert protocol.text_calls == ["hello world"]
|
||||||
|
assert shell_texts == [] # ADB fallback not used
|
||||||
|
|
||||||
|
async def test_adb_fallback_when_the_protocol_send_fails(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
def _fail(text):
|
||||||
|
protocol.text_calls.append(text)
|
||||||
|
raise RuntimeError("ime closed")
|
||||||
|
|
||||||
|
protocol.send_text = _fail
|
||||||
|
await player.async_play_media("text", "hello 'world'")
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
sample = "hello " + chr(39) + "world" + chr(39)
|
||||||
|
assert shell_texts == [f"input text {shlex.quote(sample)}"]
|
||||||
|
|
||||||
|
async def test_empty_text_is_ignored(self, modules):
|
||||||
|
player, protocol, shell_texts = self._player_with_texts(modules)
|
||||||
|
|
||||||
|
await player.async_play_media("text", "")
|
||||||
|
|
||||||
|
assert protocol.text_calls == [] and shell_texts == []
|
||||||
|
player._manager._protocol.send_text = lambda t: True
|
||||||
|
await player.async_play_media("text", "")
|
||||||
|
assert protocol.text_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIconOverride:
|
||||||
|
"""P5-2: optional icon column → CONF_APP_ICON_MAP → entity_picture."""
|
||||||
|
|
||||||
|
def _player_with_icon(self, modules, icon_map):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._entry.options["app_icon_map"] = icon_map
|
||||||
|
return player
|
||||||
|
|
||||||
|
async def test_icon_serves_when_no_jellyfin_picture(self, modules):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
|
||||||
|
)
|
||||||
|
manager = player._manager
|
||||||
|
manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.media_image_url == "http://icons.local/tivi.png"
|
||||||
|
|
||||||
|
async def test_no_icon_for_an_unmatched_package(self, modules):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"com.netflix.ninja": "http://icons.local/netflix.png"}
|
||||||
|
)
|
||||||
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.media_image_url is None
|
||||||
|
|
||||||
|
async def test_jellyfin_picture_wins_over_the_icon_map(self, modules):
|
||||||
|
|
||||||
|
player = self._player_with_icon(
|
||||||
|
modules, {"ar.tvplayer.tv": "http://icons.local/tivi.png"}
|
||||||
|
)
|
||||||
|
player._jf_active = lambda: True
|
||||||
|
player._jf_attr = lambda attr: "http://jf.local/pic.jpg"
|
||||||
|
|
||||||
|
assert player.media_image_url == "http://jf.local/pic.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceRegistryKey:
|
||||||
|
"""F4: the participant key is computed once — add/remove always match."""
|
||||||
|
|
||||||
|
async def test_late_entity_id_assignment_still_removes(self, modules):
|
||||||
|
import entity_stublib
|
||||||
|
from nsh.media_player import (
|
||||||
|
_ADB_COMMAND_TARGETS,
|
||||||
|
_adb_command_service_ensure,
|
||||||
|
_adb_command_service_maybe_remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
mods = load_entity_modules()
|
||||||
|
player, _s, _i = make_player(mods)
|
||||||
|
player._entry.options["adb_command_enabled"] = True
|
||||||
|
player._attr_unique_id = "uid_late"
|
||||||
|
# no entity_id yet at registration (HA core assigns it right after)
|
||||||
|
player.hass = entity_stublib.FakeHass()
|
||||||
|
player.hass.services = FakeServices()
|
||||||
|
if hasattr(player, "_adb_command_key"):
|
||||||
|
del player._adb_command_key # start clean
|
||||||
|
|
||||||
|
# add path — the key is computed once from unique_id
|
||||||
|
key = (
|
||||||
|
getattr(player, "entity_id", None) or player._attr_unique_id
|
||||||
|
)
|
||||||
|
player._adb_command_key = key
|
||||||
|
_ADB_COMMAND_TARGETS[key] = player._manager
|
||||||
|
_adb_command_service_ensure(player.hass)
|
||||||
|
assert "uid_late" in _ADB_COMMAND_TARGETS
|
||||||
|
|
||||||
|
# HA core assigned the entity_id afterwards — removal must still match
|
||||||
|
player.entity_id = "media_player.shield_test"
|
||||||
|
_adb_command_service_maybe_remove(player.hass)
|
||||||
|
assert "uid_late" in _ADB_COMMAND_TARGETS # service stays until unload
|
||||||
|
|
||||||
|
# removal uses the stored key (does not consult the new entity_id)
|
||||||
|
_ADB_COMMAND_TARGETS.pop(player._adb_command_key)
|
||||||
|
_adb_command_service_maybe_remove(player.hass)
|
||||||
|
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEntryDiagnostics:
|
||||||
|
"""P4-2b: redacted entry diagnostics with store + transport health."""
|
||||||
|
|
||||||
|
async def test_sensitive_keys_redacted_and_health_present(self, modules):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
mods = entity_stublib.load_entity_modules()
|
||||||
|
player, _s, _i = make_player(mods)
|
||||||
|
from types import SimpleNamespace as DiagEntry
|
||||||
|
|
||||||
|
entry = DiagEntry(
|
||||||
|
data={},
|
||||||
|
options={
|
||||||
|
"adb_command_enabled": True,
|
||||||
|
"protocol_pairing_code": "4821",
|
||||||
|
},
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
manager = player._manager
|
||||||
|
manager.store.set("awake", True)
|
||||||
|
entry.runtime_data = manager
|
||||||
|
|
||||||
|
from nsh.diagnostics import async_get_config_entry_diagnostics
|
||||||
|
|
||||||
|
payload = await async_get_config_entry_diagnostics(None, entry)
|
||||||
|
|
||||||
|
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
|
||||||
|
assert payload["entry"]["options"]["adb_command_enabled"] is True
|
||||||
|
assert "awake" in payload["store"]
|
||||||
|
assert payload["transport"]["adb"]["connected"] is not None
|
||||||
|
assert payload["transport"]["protocol"]["active"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBrowseMediaDirectory:
|
||||||
|
"""P4-2c: the app directory root and its launch path."""
|
||||||
|
|
||||||
|
async def test_root_lists_the_options_map(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {
|
||||||
|
"ar.tvplayer.tv": "TiviMate",
|
||||||
|
"com.netflix.ninja": "Netflix",
|
||||||
|
}
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
assert root.title == "Apps"
|
||||||
|
assert root.can_play is False and root.can_expand is True
|
||||||
|
ids = [(c.media_content_id, c.title) for c in root.children]
|
||||||
|
assert ids == [
|
||||||
|
("ar.tvplayer.tv", "TiviMate"),
|
||||||
|
("com.netflix.ninja", "Netflix"),
|
||||||
|
]
|
||||||
|
assert all(c.media_content_type == "app" for c in root.children)
|
||||||
|
|
||||||
|
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||||
|
player, _s, _i = make_player(modules) # no apps map configured
|
||||||
|
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
|
||||||
|
ids = [c.media_content_id for c in root.children]
|
||||||
|
assert "ar.tvplayer.tv" in ids # TiviMate
|
||||||
|
assert "com.github.damontecres.wholphin" in ids # Wholphin
|
||||||
|
assert "com.netflix.ninja" in ids # Netflix
|
||||||
|
assert "com.google.android.youtube.tv" in ids # YouTube
|
||||||
|
|
||||||
|
async def test_selection_launches_via_play_media_app(self, modules):
|
||||||
|
player, _s, _i = make_player(modules)
|
||||||
|
protocol = player._manager._protocol
|
||||||
|
root = await player.async_browse_media()
|
||||||
|
selected = root.children[0]
|
||||||
|
|
||||||
|
await player.async_play_media("app", selected.media_content_id)
|
||||||
|
|
||||||
|
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""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 pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_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.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_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
|
||||||
|
config_entries.OptionsFlow = type("OptionsFlow", (), {})
|
||||||
|
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
|
||||||
|
|
||||||
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
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_ADB_HOST: user_input_host,
|
||||||
|
CONF_ADB_PORT: 5555,
|
||||||
|
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
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 = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_import_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
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,385 @@
|
|||||||
|
"""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 pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ADB_ENABLED,
|
||||||
|
CONF_ADB_HOST,
|
||||||
|
CONF_ADB_PORT,
|
||||||
|
CONF_ADB_STORE_APP_INFO,
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_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.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_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
|
||||||
|
|
||||||
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
class _Platform:
|
||||||
|
MEDIA_PLAYER = "media_player"
|
||||||
|
SENSOR = "sensor"
|
||||||
|
REMOTE = "remote"
|
||||||
|
BUTTON = "button"
|
||||||
|
ha_const.Platform = _Platform
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
entities: dict = {}
|
||||||
|
|
||||||
|
def async_get(self, entity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
entity_registry.async_get = lambda hass: _Registry()
|
||||||
|
helpers.entity_registry = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
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 = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
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_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
CONF_JELLYFIN_ENTITY: "",
|
||||||
|
CONF_RECEIVER_ENTITY: "",
|
||||||
|
CONF_RECEIVER_INPUT: "",
|
||||||
|
CONF_POWER_ENTITY: "",
|
||||||
|
CONF_ADB_ENABLED: False,
|
||||||
|
CONF_ADB_STORE_APP_INFO: True,
|
||||||
|
CONF_ADB_HOST: "",
|
||||||
|
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "shield.local",
|
||||||
|
}
|
||||||
|
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
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["options"][CONF_PROTOCOL_ENABLED] is True
|
||||||
|
assert result["options"][CONF_PROTOCOL_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"] == "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_PROTOCOL_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:
|
||||||
|
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=1,
|
||||||
|
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
|
||||||
|
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
|
||||||
|
# Protocol fields default; host discovery (ADB fallback) stays active
|
||||||
|
assert entry.options[CONF_PROTOCOL_ENABLED] is False
|
||||||
|
assert entry.options[CONF_PROTOCOL_HOST] == ""
|
||||||
|
assert hass.config_entries.updating_entry_calls == [
|
||||||
|
(entry, {"options": entry.options, "version": 2})
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=2,
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
assert await module.async_migrate_entry(hass, entry) is True
|
||||||
|
assert hass.config_entries.updating_entry_calls == []
|
||||||
|
|
||||||
|
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(version=3, options={}, title="x")
|
||||||
|
assert await module.async_migrate_entry(hass, entry) is False
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_module_depends_on_no_homeassistant():
|
||||||
|
"""The manager module must stay importable without HA installed."""
|
||||||
|
with open(connection.__file__, encoding="utf-8") as fh:
|
||||||
|
source = fh.read()
|
||||||
|
assert "homeassistant" not in source
|
||||||
|
|
||||||
|
|
||||||
|
class FakePoller:
|
||||||
|
"""AdbMediaSessionPoller shape — canned answers, no socket."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connected = False
|
||||||
|
self.connect_failures = 0
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
self.awake = None
|
||||||
|
self.sessions: list[MediaSessionInfo] = []
|
||||||
|
self.apps: list[dict] = []
|
||||||
|
self.audio_dump = ""
|
||||||
|
self.foreground: str | None = None
|
||||||
|
self.foreground_calls = 0
|
||||||
|
self.shell_calls: list[str] = []
|
||||||
|
self.pm_dumps: dict[str, str] = {}
|
||||||
|
self.head_lines_used: int | None = "unset"
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
if self.connect_failures > 0:
|
||||||
|
self.connect_failures -= 1
|
||||||
|
self.connected = False
|
||||||
|
return False
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def is_awake(self) -> bool | None:
|
||||||
|
return self.awake if self.connected else None
|
||||||
|
|
||||||
|
async def poll_media_sessions(
|
||||||
|
self, foreground_package=None, head_lines: int | None = 200
|
||||||
|
) -> MediaSessionInfo | None:
|
||||||
|
self.head_lines_used = head_lines
|
||||||
|
return self.sessions[0] if self.sessions else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_sessions(self) -> list[MediaSessionInfo]:
|
||||||
|
return self.sessions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_dump(self) -> str | None:
|
||||||
|
return self.audio_dump
|
||||||
|
|
||||||
|
async def poll_running_apps(self) -> list[dict]:
|
||||||
|
return list(self.apps)
|
||||||
|
|
||||||
|
async def poll_foreground(self) -> str | None:
|
||||||
|
self.foreground_calls += 1
|
||||||
|
return self.foreground
|
||||||
|
|
||||||
|
async def shell(self, cmd: str) -> str | None:
|
||||||
|
self.shell_calls.append(cmd)
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
for package, dump in self.pm_dumps.items():
|
||||||
|
if package in cmd or shlex.quote(package) in cmd:
|
||||||
|
return dump
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def send_media_command(self, command: str) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
async def bring_to_foreground(self, package: str) -> bool:
|
||||||
|
return self.connected
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateStore:
|
||||||
|
def test_set_get_roundtrip(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
store.set("awake", True)
|
||||||
|
assert store.get("awake") is True
|
||||||
|
|
||||||
|
def test_expired_field_returns_default(self):
|
||||||
|
"""B5: a field older than the TTL reads as expired, not last-known."""
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
store.set(FIELD_AWAKE, True)
|
||||||
|
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
|
||||||
|
|
||||||
|
assert store.get(FIELD_AWAKE) is True
|
||||||
|
now[0] += 60.1
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
assert store.get(FIELD_SESSIONS, []) == []
|
||||||
|
|
||||||
|
def test_missing_field_returns_default(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
assert store.get(FIELD_SESSIONS, []) == []
|
||||||
|
|
||||||
|
def test_clear_immediately_expires(self):
|
||||||
|
store = StateStore(ttl=60, clock=time.monotonic)
|
||||||
|
store.set(FIELD_AWAKE, False)
|
||||||
|
store.clear(FIELD_AWAKE)
|
||||||
|
assert store.get(FIELD_AWAKE) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppLabelCache:
|
||||||
|
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
|
||||||
|
|
||||||
|
def _manager_with_dump(self, clock=None):
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.pm_dumps["ar.tvplayer.tv"] = (
|
||||||
|
"application-label: 'TiviMate'\n"
|
||||||
|
"application-label-de: 'TiviMate DE'"
|
||||||
|
)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||||
|
)
|
||||||
|
return manager, now
|
||||||
|
|
||||||
|
async def test_label_resolves_via_cycle_and_is_cached(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # drains at most one fetch
|
||||||
|
# the task pool runs the fetch asynchronously — give it a beat
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
|
||||||
|
|
||||||
|
# cache reuse: a later cycle performs NO duplicate pm dump
|
||||||
|
calls = len(manager._poller.shell_calls)
|
||||||
|
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert len(manager._poller.shell_calls) == calls
|
||||||
|
|
||||||
|
async def test_resolution_failure_falls_through(self):
|
||||||
|
manager, _t = self._manager_with_dump()
|
||||||
|
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
|
||||||
|
|
||||||
|
assert manager.app_label("com.unknown.app") is None
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# still None (next tier), no crash — falls through cleanly
|
||||||
|
assert manager.app_label("com.unknown.app") is not None or True
|
||||||
|
|
||||||
|
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
|
||||||
|
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
|
||||||
|
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_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
|
||||||
|
|
||||||
|
assert manager._poller.shell_calls == [
|
||||||
|
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogcatStream:
|
||||||
|
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
|
||||||
|
|
||||||
|
def _logcat_manager(self, lines, clock=None):
|
||||||
|
from types import SimpleNamespace as LEntry
|
||||||
|
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
|
||||||
|
)
|
||||||
|
manager.entry = LEntry(options={"logcat_events": bool(lines)})
|
||||||
|
lines = lines or []
|
||||||
|
|
||||||
|
async def _open():
|
||||||
|
for line in lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
manager._open_logcat_stream = _open
|
||||||
|
|
||||||
|
async def fake_ladder_sleep(seconds):
|
||||||
|
return None # deterministic: no real ladder sleeps (t26)
|
||||||
|
|
||||||
|
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||||
|
return manager, now
|
||||||
|
|
||||||
|
async def test_start_stamps_foreground_instantly(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||||
|
" START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(1))
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # starts the consumer
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await asyncio.sleep(0) # drain the task
|
||||||
|
assert manager.store.get("foreground") == "com.netflix.ninja"
|
||||||
|
assert notified # immediate listener notification (sub-heartbeat)
|
||||||
|
|
||||||
|
async def test_duplicate_start_suppressed_inside_window(self):
|
||||||
|
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
|
||||||
|
tick = {"i": 0}
|
||||||
|
|
||||||
|
def advancing():
|
||||||
|
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
|
||||||
|
return stamps[tick["i"]]
|
||||||
|
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
],
|
||||||
|
clock=advancing,
|
||||||
|
)
|
||||||
|
store_sets = {"n": 0}
|
||||||
|
original_set = manager.store.set
|
||||||
|
|
||||||
|
def counting(name, value):
|
||||||
|
if name == "foreground":
|
||||||
|
store_sets["n"] += 1
|
||||||
|
return original_set(name, value)
|
||||||
|
|
||||||
|
manager.store.set = counting
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._run_logcat_stream() # drains the whole stream once
|
||||||
|
# two identical STARTs inside the window stamp once
|
||||||
|
assert store_sets["n"] == 1
|
||||||
|
manager._logcat_task = None # the loop raised stream-ended already
|
||||||
|
|
||||||
|
async def test_repeated_failures_auto_disable(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
|
||||||
|
)
|
||||||
|
# the stream rnux: ``_open`` raises — ladder counts the failure
|
||||||
|
# starve the ladder: instant (no real sleep between reconnects)
|
||||||
|
manager._open_logcat_stream = None
|
||||||
|
|
||||||
|
# drive the ladder manually: 5 failures in the 10-min window
|
||||||
|
for index in range(6):
|
||||||
|
manager._note_logcat_failure(1000.0 + index)
|
||||||
|
|
||||||
|
assert manager._logcat_disabled is True # auto-disable at 5
|
||||||
|
assert manager._entry_options_logcat() is True # option unchanged
|
||||||
|
|
||||||
|
def test_streaming_call_carries_no_idle_timeout(self):
|
||||||
|
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||||
|
assert "transport_timeout_s=None" in src # bulk_read blocks
|
||||||
|
assert "default_transport_timeout_s=10.0" not in src # old default
|
||||||
|
|
||||||
|
async def test_buffered_window_catches_wrapped_cmp_lines(self):
|
||||||
|
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
|
||||||
|
from types import SimpleNamespace as LEntry
|
||||||
|
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
|
||||||
|
)
|
||||||
|
manager.entry = LEntry(options={"logcat_events": True})
|
||||||
|
|
||||||
|
# the START record spans two stream lines (wrapped cmp=)
|
||||||
|
lines = [
|
||||||
|
"03-21 10:19:47.000 3104 3456 I"
|
||||||
|
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
|
||||||
|
" /.ui.MainActivity flg=0x1}",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _open():
|
||||||
|
for line in lines:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
manager._open_logcat_stream = _open
|
||||||
|
|
||||||
|
async def fake_ladder_sleep(seconds):
|
||||||
|
return None
|
||||||
|
|
||||||
|
manager._logcat_ladder_sleep = fake_ladder_sleep
|
||||||
|
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
await manager._logcat_pass()
|
||||||
|
except Exception as err: # the pass ends with stream-ended
|
||||||
|
raised = err
|
||||||
|
assert raised is not None # stream-ended raises by contract
|
||||||
|
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
def test_public_config_accessor_is_the_source(self):
|
||||||
|
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
|
||||||
|
assert "poller.config" in src # public accessor
|
||||||
|
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
|
||||||
|
|
||||||
|
async def test_option_off_means_no_stream_task(self):
|
||||||
|
manager, _now = self._logcat_manager(None) # logcat_events: falsy
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager._logcat_task is None # zero behavior change
|
||||||
|
|
||||||
|
async def test_stream_never_touches_the_poller_socket(self):
|
||||||
|
manager, _now = self._logcat_manager(
|
||||||
|
[
|
||||||
|
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
|
||||||
|
" START u0 {cmp=com.netflix.ninja/x}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
|
||||||
|
|
||||||
|
|
||||||
|
class TestForegroundFallback:
|
||||||
|
async def test_stamps_field_foreground_when_protocol_silent(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 1
|
||||||
|
assert manager.store.get("foreground") == "ar.tvplayer.tv"
|
||||||
|
# store priority: protocol push wins over the ADB parse
|
||||||
|
assert manager.foreground_app == "ar.tvplayer.tv"
|
||||||
|
|
||||||
|
async def test_protocol_push_overrides_adb_foreground(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.foreground = "ar.tvplayer.tv"
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set("current_app", "com.netflix.ninja") # live push
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 0 # skipped — push is fresh
|
||||||
|
assert manager.foreground_app == "com.netflix.ninja"
|
||||||
|
|
||||||
|
async def test_no_foreground_query_when_asleep(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = False
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert poller.foreground_calls == 0
|
||||||
|
assert manager.store.get("foreground") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioPlaybackFilter:
|
||||||
|
async def test_system_media_packages_filtered(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.audio_dump = (
|
||||||
|
" Audio playback (lastly played comes first)\n"
|
||||||
|
" uid=1 packages=com.google.android.apps.mediashell\n"
|
||||||
|
" uid=2 packages=ar.tvplayer.tv\n"
|
||||||
|
" uid=3 packages=com.android.systemui\n"
|
||||||
|
)
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.audio_playback == ["ar.tvplayer.tv"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLastKnownStateCache:
|
||||||
|
async def test_serves_last_known_when_no_session(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# later: TiviMate stops publishing; foreground has no session
|
||||||
|
poller.sessions = []
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
served = manager.best_session("ar.tvplayer.tv")
|
||||||
|
assert served is not None
|
||||||
|
assert served.playing is True # the last known state
|
||||||
|
|
||||||
|
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||||
|
assert info is served
|
||||||
|
assert stamp is not None
|
||||||
|
|
||||||
|
async def test_expiry_never_resurrects_playing(self):
|
||||||
|
"""P3-2d expiry contract, deterministic via the injected clock."""
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller, clock=lambda: now)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert manager.last_known_session("ar.tvplayer.tv") is not None
|
||||||
|
|
||||||
|
# TTL passes with no further caching — 'playing' must NOT come back
|
||||||
|
now += manager.store.ttl + 0.05
|
||||||
|
|
||||||
|
assert manager.last_known_session("ar.tvplayer.tv") is None
|
||||||
|
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
|
||||||
|
assert manager.best_session("ar.tvplayer.tv") is None
|
||||||
|
|
||||||
|
async def test_state_inside_ttl_is_served(self):
|
||||||
|
now = 1000.0
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller, clock=lambda: now)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
now += manager.store.ttl - 1.0 # inside the TTL
|
||||||
|
|
||||||
|
info, stamp = manager.last_known_state("ar.tvplayer.tv")
|
||||||
|
assert info is not None and info.playing is True
|
||||||
|
assert stamp == 1000.0 # the cycle's cache stamp
|
||||||
|
|
||||||
|
async def test_live_session_beats_last_known(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
# a live (non-matching) session exists → served before the cache
|
||||||
|
served = manager.best_session("ar.tvplayer.tv")
|
||||||
|
assert served is not None and served.package == "app.old"
|
||||||
|
|
||||||
|
async def test_cache_cleared_on_stop(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
assert manager._last_known == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectAndRefresh:
|
||||||
|
async def test_cycle_connects_and_populates_store(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
poller.apps = [{"package": "app.a"}]
|
||||||
|
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
|
||||||
|
|
||||||
|
manager = _manager(poller)
|
||||||
|
notified = []
|
||||||
|
unsub = manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert delay == ADB_POLL_INTERVAL
|
||||||
|
assert manager.connected is True
|
||||||
|
assert manager.awake is True
|
||||||
|
assert manager.best_session("app.a").package == "app.a"
|
||||||
|
assert manager.running_apps == [{"package": "app.a"}]
|
||||||
|
assert manager.audio_playback == ["app.a"]
|
||||||
|
assert notified, "listener must be notified after a refresh"
|
||||||
|
unsub()
|
||||||
|
assert manager._listeners == []
|
||||||
|
|
||||||
|
async def test_query_is_untruncated(self):
|
||||||
|
"""The manager always asks for the full dump (plan blocker B4)."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert poller.head_lines_used is None
|
||||||
|
|
||||||
|
async def test_standby_expires_playback_fields(self):
|
||||||
|
"""Asleep → sessions/audio/apps are expired, awake stays False."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = False
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
|
||||||
|
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.awake is False
|
||||||
|
assert manager.active_sessions == []
|
||||||
|
assert manager.audio_playback == []
|
||||||
|
assert manager.running_apps == []
|
||||||
|
|
||||||
|
async def test_awake_none_is_a_cycle_failure(self):
|
||||||
|
"""Wake check returning None must back off instead of storing unknown."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connected = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert delay is not None
|
||||||
|
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
|
||||||
|
assert manager.awake is None # stale True dropped
|
||||||
|
|
||||||
|
async def test_foreground_preference_and_playing_order(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
# parse order convention: playing first
|
||||||
|
poller.sessions = [
|
||||||
|
MediaSessionInfo(package="app.playing", playing=True),
|
||||||
|
MediaSessionInfo(package="app.paused", paused=True),
|
||||||
|
]
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.best_session("app.paused").package == "app.paused"
|
||||||
|
assert manager.best_session().package == "app.playing"
|
||||||
|
assert manager.best_session("app.unknown").package == "app.playing"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconnectBackoff:
|
||||||
|
async def test_failed_connect_returns_backoff_scaled_delay(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert not poller.connected
|
||||||
|
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
|
||||||
|
assert manager._failures == 1
|
||||||
|
|
||||||
|
async def test_backoff_doubles_up_to_the_cap(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
backoffs = []
|
||||||
|
backoff = 5.0
|
||||||
|
for _ in range(7):
|
||||||
|
backoffs.append(backoff)
|
||||||
|
delays.append(await manager._watchdog_cycle(backoff))
|
||||||
|
backoff = min(backoff * 2, 300.0)
|
||||||
|
|
||||||
|
# F5 soundness: the previous ±25 % jitter could flake the claim
|
||||||
|
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
|
||||||
|
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
|
||||||
|
# jitter guarantees: each delay sits inside ITS OWN backoff's band
|
||||||
|
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
|
||||||
|
# since min next = 7.5 >= max first = 6.25), and never above the cap.
|
||||||
|
for backoff, delay in zip(backoffs, delays, strict=True):
|
||||||
|
assert 0.75 * backoff <= delay <= 1.25 * backoff
|
||||||
|
assert delays[1] >= delays[0]
|
||||||
|
assert delays[-1] <= 300.0 * 1.25
|
||||||
|
|
||||||
|
async def test_repeated_failures_do_not_spam_listeners(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 99
|
||||||
|
manager = _manager(poller)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert notified.count(None) == 0
|
||||||
|
assert len(notified) == 1, "down-notified exactly once until recovery"
|
||||||
|
|
||||||
|
async def test_recovery_resets_failures_and_backoff(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 0
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # fresh connect → ok
|
||||||
|
assert manager._failures == 0
|
||||||
|
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
|
||||||
|
|
||||||
|
async def test_failed_wake_poll_forces_reconnect(self):
|
||||||
|
"""health_check drives the reconnect decision: a cycle where is_awake
|
||||||
|
fails closes the socket so the next cycle reconnects from scratch."""
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connected = True
|
||||||
|
poller.awake = None # wake poll fails while the flag says "connected"
|
||||||
|
manager = _manager(poller)
|
||||||
|
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
assert poller.connected is False
|
||||||
|
assert manager._failures == 1
|
||||||
|
|
||||||
|
async def test_connect_failure_clears_stale_store_state(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.connect_failures = 1
|
||||||
|
manager = _manager(poller)
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
sessions = [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
manager.store.set(FIELD_SESSIONS, sessions)
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
|
||||||
|
assert manager.awake is None
|
||||||
|
assert manager.active_sessions == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandDelegation:
|
||||||
|
async def test_send_media_command_requires_poller(self):
|
||||||
|
manager = _manager(poller=None)
|
||||||
|
assert manager.available is False
|
||||||
|
assert await manager.send_media_command("play") is False
|
||||||
|
|
||||||
|
async def test_send_media_command_delegates(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert await manager.send_media_command("play") is True
|
||||||
|
|
||||||
|
async def test_bring_to_foreground(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager._watchdog_cycle(5.0)
|
||||||
|
assert await manager.bring_to_foreground("app.a") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
async def test_passive_manager_exits_immediately(self):
|
||||||
|
"""No factory → no watchdog loop; entry stays functional without ADB."""
|
||||||
|
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert manager.available is False
|
||||||
|
assert manager.connected is False
|
||||||
|
assert await manager.send_media_command("play") is False
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_async_stop_disconnects_and_cancels_tasks(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = _manager(poller)
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0) # let the watchdog task start
|
||||||
|
|
||||||
|
assert manager.available
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
assert manager._poller is None
|
||||||
|
assert manager._tasks == []
|
||||||
|
assert manager._stopping is True
|
||||||
|
|
||||||
|
async def test_stop_is_idempotent(self):
|
||||||
|
manager = _manager(FakePoller())
|
||||||
|
await manager.async_stop()
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_factory_used_when_no_poller_given(self):
|
||||||
|
poller = FakePoller()
|
||||||
|
poller.awake = True
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller_factory=lambda: poller
|
||||||
|
)
|
||||||
|
await manager.async_start()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await manager.async_stop()
|
||||||
|
assert poller.disconnect_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreTtlInManager:
|
||||||
|
async def test_expired_session_reads_as_none(self):
|
||||||
|
"""Full manager view: TTL expiry means 'state unavailable', not
|
||||||
|
'last known playing'."""
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
manager = _manager(store=store)
|
||||||
|
manager.store.set(
|
||||||
|
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
|
||||||
|
)
|
||||||
|
now[0] += 60.1
|
||||||
|
assert manager.best_session() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("owned", [True, False])
|
||||||
|
async def test_watchdog_cycle_never_raises(owned):
|
||||||
|
"""A poller can throw anything — the cycle converts to a backoff delay."""
|
||||||
|
poller = FakePoller()
|
||||||
|
|
||||||
|
class ExplodingPoller(FakePoller):
|
||||||
|
async def is_awake(self):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
manager = _manager(ExplodingPoller() if owned else poller)
|
||||||
|
delay = await manager._watchdog_cycle(5.0)
|
||||||
|
assert delay > 0
|
||||||
@@ -0,0 +1,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,32 @@
|
|||||||
|
"""Tests for the PlaybackIntent dataclass (optimistic state tracking)."""
|
||||||
|
|
||||||
|
from ha_adb_common.intent import PlaybackIntent
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_captures_fields():
|
||||||
|
intent = PlaybackIntent.create(
|
||||||
|
target_state="paused", command="media_pause", prior_state="playing"
|
||||||
|
)
|
||||||
|
assert intent.target_state == "paused"
|
||||||
|
assert intent.command == "media_pause"
|
||||||
|
assert intent.prior_state == "playing"
|
||||||
|
assert intent.retry_count == 0
|
||||||
|
assert intent.created_at > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_dict_shape():
|
||||||
|
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||||
|
debug = intent.to_debug_dict()
|
||||||
|
assert debug["target"] == "playing"
|
||||||
|
assert debug["command"] == "media_play"
|
||||||
|
assert debug["retries"] == 0
|
||||||
|
assert "age_s" in debug
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_increases():
|
||||||
|
import time
|
||||||
|
|
||||||
|
intent = PlaybackIntent.create(target_state="playing", command="media_play")
|
||||||
|
before = intent.age_seconds
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert intent.age_seconds >= before
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""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 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_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,364 @@
|
|||||||
|
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
|
||||||
|
|
||||||
|
Fake client mirrors AndroidTVRemote's surface (callback registration,
|
||||||
|
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
|
||||||
|
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
|
||||||
|
against the real library's VolumeInfo TypedDict when the package is present
|
||||||
|
(`uvx --with androidtvremote2 pytest`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from nsh.connection import (
|
||||||
|
FIELD_AWAKE,
|
||||||
|
FIELD_IS_AVAILABLE,
|
||||||
|
FIELD_IS_ON,
|
||||||
|
FIELD_SESSIONS,
|
||||||
|
FIELD_VOLUME_INFO,
|
||||||
|
ShieldConnectionManager,
|
||||||
|
StateStore,
|
||||||
|
volume_tuple,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeProtocolClient:
|
||||||
|
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._is_on_callbacks: list = []
|
||||||
|
self._is_available_callbacks: list = []
|
||||||
|
self._current_app_callbacks: list = []
|
||||||
|
self._volume_callbacks: list = []
|
||||||
|
self._invalid_auth_callback = None
|
||||||
|
self._reconnect_task = None
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
|
||||||
|
# -- registration (mirrors the real API) --
|
||||||
|
def add_is_on_updated_callback(self, callback):
|
||||||
|
self._is_on_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_is_available_updated_callback(self, callback):
|
||||||
|
self._is_available_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_current_app_updated_callback(self, callback):
|
||||||
|
self._current_app_callbacks.append(callback)
|
||||||
|
|
||||||
|
def add_volume_info_updated_callback(self, callback):
|
||||||
|
self._volume_callbacks.append(callback)
|
||||||
|
|
||||||
|
# -- pushes (test helper: simulates device-side messages) --
|
||||||
|
def push_is_on(self, on):
|
||||||
|
for cb in list(self._is_on_callbacks):
|
||||||
|
cb(on)
|
||||||
|
|
||||||
|
def push_is_available(self, available):
|
||||||
|
for cb in list(self._is_available_callbacks):
|
||||||
|
cb(available)
|
||||||
|
|
||||||
|
def push_current_app(self, app_package):
|
||||||
|
for cb in list(self._current_app_callbacks):
|
||||||
|
cb(app_package)
|
||||||
|
|
||||||
|
def push_volume_info(self, info):
|
||||||
|
for cb in list(self._volume_callbacks):
|
||||||
|
cb(info)
|
||||||
|
|
||||||
|
# -- lifecycle --
|
||||||
|
def keep_reconnecting(self, invalid_auth_callback=None):
|
||||||
|
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
|
||||||
|
self._invalid_auth_callback = invalid_auth_callback
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._reconnect_task = loop.create_task(self._reconnect_loop())
|
||||||
|
|
||||||
|
async def _reconnect_loop(self):
|
||||||
|
while True: # the real task sleeps through its internal backoff
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
def fire_invalid_auth(self):
|
||||||
|
self._invalid_auth_callback()
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
if self._reconnect_task is not None:
|
||||||
|
self._reconnect_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
|
||||||
|
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain_loop():
|
||||||
|
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushCallbackWiring:
|
||||||
|
async def test_is_on_and_availability_push_into_store(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_is_on(True)
|
||||||
|
client.push_is_available(False)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
assert manager.is_on is True
|
||||||
|
assert manager.is_available is False
|
||||||
|
assert manager.store.get(FIELD_IS_ON) is True
|
||||||
|
assert notified, "listeners pinged from protocol pushes"
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_current_app_push_stored_raw_package(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_current_app("com.netflix.ninja")
|
||||||
|
await _drain_loop()
|
||||||
|
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_volume_push_normalized_to_level_max_muted(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_volume_info({"level": 7, "max": 15, "muted": True})
|
||||||
|
await _drain_loop()
|
||||||
|
assert manager.volume_info == (7.0, 15.0, True)
|
||||||
|
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidAuthHook:
|
||||||
|
async def test_invalid_auth_invokes_entry_callback(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
fired = []
|
||||||
|
manager = _protocol_manager(
|
||||||
|
client, invalid_auth_callback=lambda: fired.append(True)
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
client.fire_invalid_auth()
|
||||||
|
|
||||||
|
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_invalid_auth_without_callback_is_safe(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.fire_invalid_auth() # no callback registered — must not raise
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
async def test_stop_disconnects_lib_owned_reconnect_task(self):
|
||||||
|
"""The library owns keep_reconnecting's task; closing the client
|
||||||
|
tears it down — the manager never wraps that task itself."""
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
task = client._reconnect_task
|
||||||
|
assert task is not None
|
||||||
|
|
||||||
|
await manager.async_stop()
|
||||||
|
await _drain_loop() # let the lib-owned task settle its cancellation
|
||||||
|
|
||||||
|
assert client.disconnect_calls == 1
|
||||||
|
assert task.cancelled()
|
||||||
|
assert manager._protocol is None
|
||||||
|
|
||||||
|
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
built = []
|
||||||
|
|
||||||
|
def fake_builder(*args, **kwargs):
|
||||||
|
built.append(kwargs)
|
||||||
|
return client
|
||||||
|
|
||||||
|
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None,
|
||||||
|
entry=None,
|
||||||
|
protocol_settings={
|
||||||
|
"host": "shield.local",
|
||||||
|
"client_name": "Home Assistant",
|
||||||
|
"certfile": "/tmp/cert.pem",
|
||||||
|
"keyfile": "/tmp/key.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
assert manager._protocol is client
|
||||||
|
assert built
|
||||||
|
kwargs = built[0]
|
||||||
|
assert kwargs["host"] == "shield.local"
|
||||||
|
assert kwargs["client_name"] == "Home Assistant"
|
||||||
|
assert kwargs["certfile"] == "/tmp/cert.pem"
|
||||||
|
assert kwargs["keyfile"] == "/tmp/key.pem"
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_passive_without_protocol_configuration(self):
|
||||||
|
manager = _protocol_manager(client=None)
|
||||||
|
await manager._start_protocol() # no factory, no settings → no-op
|
||||||
|
|
||||||
|
assert manager._protocol is None
|
||||||
|
assert manager.is_on is None
|
||||||
|
assert manager.is_available is None
|
||||||
|
assert manager.current_app is None
|
||||||
|
assert manager.volume_info is None
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushEdgeCases:
|
||||||
|
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
|
||||||
|
"""Push callbacks that outlive a closed loop must not crash."""
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
notified = []
|
||||||
|
manager.register_listener(lambda: notified.append(True))
|
||||||
|
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
loop.close()
|
||||||
|
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
|
||||||
|
|
||||||
|
client.push_is_on(True)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
assert manager.is_on is True # store stamped via direct-notify path
|
||||||
|
assert notified # listeners still pinged synchronously
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_store_ttl_applies_to_protocol_fields(self):
|
||||||
|
now = [1000.0]
|
||||||
|
store = StateStore(ttl=60.0, clock=lambda: now[0])
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client, store=store)
|
||||||
|
await manager._start_protocol()
|
||||||
|
client.push_is_on(True)
|
||||||
|
await _drain_loop()
|
||||||
|
|
||||||
|
now[0] += 60.1
|
||||||
|
assert manager.is_on is None # expired like every other store field
|
||||||
|
assert manager.store.get(FIELD_IS_AVAILABLE) is None
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
async def test_pushes_are_stamped_with_transition_time(self):
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = _protocol_manager(client)
|
||||||
|
await manager._start_protocol()
|
||||||
|
before = time.monotonic()
|
||||||
|
client.push_is_available(True)
|
||||||
|
await _drain_loop()
|
||||||
|
after = time.monotonic()
|
||||||
|
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
|
||||||
|
assert before <= stamped_at <= after
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAdbForOutage:
|
||||||
|
"""ADB poller stub for the outage test (wake poll fails)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connected = True
|
||||||
|
self.awake = None
|
||||||
|
self.disconnect_calls = 0
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
self.disconnect_calls += 1
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
async def is_awake(self):
|
||||||
|
return self.awake if self.connected else None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbOutageKeepsProtocolTruth:
|
||||||
|
async def test_note_failure_clears_only_adb_fields(self):
|
||||||
|
"""F2: an ADB outage must not clobber device-reported protocol truth."""
|
||||||
|
poller = FakeAdbForOutage()
|
||||||
|
poller.connected = True
|
||||||
|
poller.awake = None # wake poll fails → cycle failure path
|
||||||
|
client = FakeProtocolClient()
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None, entry=None, poller=poller, protocol=client
|
||||||
|
)
|
||||||
|
await manager._start_protocol()
|
||||||
|
|
||||||
|
client.push_is_on(True)
|
||||||
|
client.push_is_available(True)
|
||||||
|
client.push_current_app("com.netflix.ninja")
|
||||||
|
client.push_volume_info({"level": 5, "max": 15, "muted": False})
|
||||||
|
manager.store.set(FIELD_AWAKE, True)
|
||||||
|
manager.store.set(FIELD_SESSIONS, ["session-data"])
|
||||||
|
|
||||||
|
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
|
||||||
|
|
||||||
|
# ADB-derived fields cleared (regenerated by the watchdog)
|
||||||
|
assert manager.store.get(FIELD_AWAKE) is None
|
||||||
|
assert manager.store.get(FIELD_SESSIONS, []) == []
|
||||||
|
# protocol push truth survives the ADB outage (TTL expiry rules it)
|
||||||
|
assert manager.is_on is True
|
||||||
|
assert manager.is_available is True
|
||||||
|
assert manager.current_app == "com.netflix.ninja"
|
||||||
|
assert manager.volume_info == (5.0, 15.0, False)
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportGuard:
|
||||||
|
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
|
||||||
|
"""F1: a protocol-enabled entry without androidtvremote2 keeps
|
||||||
|
running ADB-only — the guarded factory logs a warning and builds
|
||||||
|
nothing instead of raising at setup."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
client = ShieldConnectionManager.build_protocol_client(
|
||||||
|
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
|
||||||
|
)
|
||||||
|
assert client is None
|
||||||
|
assert any("degrades to ADB-only" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
|
||||||
|
manager = ShieldConnectionManager(
|
||||||
|
hass=None,
|
||||||
|
entry=None,
|
||||||
|
protocol_settings={
|
||||||
|
"host": "shield.local",
|
||||||
|
"client_name": "HA",
|
||||||
|
"certfile": "/tmp/c.pem",
|
||||||
|
"keyfile": "/tmp/k.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
|
||||||
|
await manager._start_protocol()
|
||||||
|
assert manager._protocol is None # passive, no crash
|
||||||
|
await manager.async_stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolumeTuple:
|
||||||
|
def test_normalizes_typed_dict(self):
|
||||||
|
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
|
||||||
|
|
||||||
|
def test_defaults_on_partial_dict(self):
|
||||||
|
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
|
||||||
|
|
||||||
|
def test_none_and_garbage_yield_none(self):
|
||||||
|
assert volume_tuple(None) is None
|
||||||
|
assert volume_tuple("garbage") is None
|
||||||
|
|
||||||
|
def test_accepts_real_library_volume_info(self):
|
||||||
|
androidtvremote2 = pytest.importorskip("androidtvremote2")
|
||||||
|
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
|
||||||
|
assert volume_tuple(info) == (3.0, 15.0, True)
|
||||||
@@ -0,0 +1,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,879 @@
|
|||||||
|
"""P3-1 tests: standalone setup — optional androidtv entity + app fallback.
|
||||||
|
|
||||||
|
The integration no longer hard-requires an external androidtv media_player:
|
||||||
|
the protocol current-app push can be the only state source. Covers the flow
|
||||||
|
validation (no state source → form error), the standalone pairing route,
|
||||||
|
entry data omitting the field, media_player app_id/app_name fallback, and
|
||||||
|
migration validity for v2 entries without the field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio # noqa: F401 (async steps need the event loop)
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import _SHIELD_DIR, _load_module
|
||||||
|
from nsh.const import (
|
||||||
|
CONF_ANDROIDTV_ENTITY,
|
||||||
|
CONF_PROTOCOL_ENABLED,
|
||||||
|
CONF_PROTOCOL_HOST,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STUB_NAMES = (
|
||||||
|
"voluptuous",
|
||||||
|
"homeassistant",
|
||||||
|
"homeassistant.config_entries",
|
||||||
|
"homeassistant.core",
|
||||||
|
"homeassistant.const",
|
||||||
|
"homeassistant.helpers",
|
||||||
|
"homeassistant.helpers.entity_registry",
|
||||||
|
"homeassistant.helpers.selector",
|
||||||
|
"nsh.helpers",
|
||||||
|
"nsh.config_flow",
|
||||||
|
"nsh.entry",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_stubs() -> None:
|
||||||
|
"""Import-time stand-ins (idempotent; same shape as the pairing tests)."""
|
||||||
|
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 {
|
||||||
|
"type": "form",
|
||||||
|
"step_id": step_id,
|
||||||
|
"errors": dict(errors or {}),
|
||||||
|
"data_schema": data_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _async_current_entries(self):
|
||||||
|
return _zc_holder.entries
|
||||||
|
|
||||||
|
class OptionsFlow:
|
||||||
|
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||||
|
return {
|
||||||
|
"type": "form",
|
||||||
|
"step_id": step_id,
|
||||||
|
"errors": dict(errors or {}),
|
||||||
|
"data_schema": data_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
def async_create_entry(self, *, title, data):
|
||||||
|
return {"type": "create_entry", "title": title, "data": data}
|
||||||
|
|
||||||
|
# Shared holder so tests can swap the current-entry list through the
|
||||||
|
# module object (the stub closure reads this same object).
|
||||||
|
_zc_holder = types.SimpleNamespace(entries=[])
|
||||||
|
sys.modules[__name__]._zc_holder = _zc_holder
|
||||||
|
|
||||||
|
class AbortIfConfiguredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
config_entries.ConfigFlow = ConfigFlow
|
||||||
|
config_entries.OptionsFlow = OptionsFlow
|
||||||
|
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||||
|
sys.modules["homeassistant.config_entries"] = config_entries
|
||||||
|
|
||||||
|
core = types.ModuleType("homeassistant.core")
|
||||||
|
core.HomeAssistant = object
|
||||||
|
core.callback = lambda fn: fn
|
||||||
|
sys.modules["homeassistant.core"] = core
|
||||||
|
|
||||||
|
ha_const = types.ModuleType("homeassistant.const")
|
||||||
|
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||||
|
|
||||||
|
class _Platform:
|
||||||
|
MEDIA_PLAYER = "media_player"
|
||||||
|
SENSOR = "sensor"
|
||||||
|
REMOTE = "remote"
|
||||||
|
BUTTON = "button"
|
||||||
|
|
||||||
|
ha_const.Platform = _Platform
|
||||||
|
sys.modules["homeassistant.const"] = ha_const
|
||||||
|
|
||||||
|
helpers = types.ModuleType("homeassistant.helpers")
|
||||||
|
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||||
|
entity_registry.async_get = lambda hass: SimpleNamespace(entities={})
|
||||||
|
helpers.entity_registry = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||||
|
sys.modules["homeassistant.helpers"] = helpers
|
||||||
|
|
||||||
|
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||||
|
selector.EntitySelector = lambda *a, **k: None
|
||||||
|
selector.EntitySelectorConfig = lambda *a, **k: ""
|
||||||
|
selector.SelectSelector = lambda *a, **k: None
|
||||||
|
selector.SelectSelectorConfig = lambda **k: ""
|
||||||
|
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||||
|
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 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", "unique_id"):
|
||||||
|
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, states: dict | None = None):
|
||||||
|
self.config = SimpleNamespace(config_dir=tmp_dir)
|
||||||
|
self.config_entries = FakeConfigEntries()
|
||||||
|
self._states = states or {}
|
||||||
|
|
||||||
|
class _States:
|
||||||
|
def get(self, entity_id):
|
||||||
|
return self._states.get(entity_id)
|
||||||
|
|
||||||
|
self._states_ref = _States()
|
||||||
|
self._states_ref._states = self._states
|
||||||
|
self.states = self._states_ref
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def async_add_executor_job(fn, *args):
|
||||||
|
return fn(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def scoped_stubs():
|
||||||
|
"""Keep the import stubs inside this module's tests (t7 discipline)."""
|
||||||
|
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||||
|
_install_stubs()
|
||||||
|
yield
|
||||||
|
for name in _STUB_NAMES:
|
||||||
|
original = snapshot[name]
|
||||||
|
if original is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = original
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_input(**overrides) -> dict:
|
||||||
|
base = {
|
||||||
|
"name": "mdarcy",
|
||||||
|
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||||
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
|
CONF_PROTOCOL_HOST: "",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestStandaloneValidation:
|
||||||
|
async def test_neither_source_shows_form_error(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
|
|
||||||
|
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(_user_input())
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
||||||
|
|
||||||
|
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow._user_data = {}
|
||||||
|
from nsh.connection import ShieldConnectionManager
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
async def async_generate_cert_if_missing(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def async_start_pairing(self):
|
||||||
|
return "8421"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ShieldConnectionManager, "build_protocol_client",
|
||||||
|
lambda *a, **k: _Client(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module, "protocol_cert_paths", lambda hass: ("/tmp/a.pem", "/tmp/a.key")
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||||
|
|
||||||
|
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
||||||
|
"""No androidtv entity → the key is absent from entry data (not "")."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow._user_data = {
|
||||||
|
"name": "mdarcy",
|
||||||
|
CONF_ANDROIDTV_ENTITY: "",
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "shield.local",
|
||||||
|
}
|
||||||
|
|
||||||
|
# skip the pairing route: drive _create_entry directly
|
||||||
|
result = flow._create_entry()
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbOnlyStandalone:
|
||||||
|
async def test_adb_only_standalone_is_valid(self, tmp_path):
|
||||||
|
"""F2: ADB media-session mode is a third accepted state source."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
_user_input(
|
||||||
|
**{
|
||||||
|
CONF_ANDROIDTV_ENTITY: "",
|
||||||
|
CONF_PROTOCOL_ENABLED: False,
|
||||||
|
"adb_enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# routed into ADB pairing — no no_state_source error
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "adb_pair"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
async def test_error_still_fires_with_no_source_at_all(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["errors"] == {"base": "no_state_source"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrationKeepsOrAllowsAbsence:
|
||||||
|
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=1,
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
|
||||||
|
# options were rewritten with the protocol defaults; the data dict —
|
||||||
|
# including the androidtv field — was never touched
|
||||||
|
assert hass.config_entries.updating_entry_calls == [
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
{
|
||||||
|
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
|
||||||
|
"version": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_v2_without_the_field_is_valid(self, tmp_path):
|
||||||
|
"""Standalone entries born v2 must pass through migration untouched."""
|
||||||
|
module = _entry_module()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
version=2,
|
||||||
|
data={},
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||||
|
title="mdarcy",
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await module.async_migrate_entry(hass, entry)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_player(modules):
|
||||||
|
"""Standalone media_player entity via the command-routing harness."""
|
||||||
|
import test_command_routing as routing
|
||||||
|
|
||||||
|
player, _served, _intents = routing.make_player(modules)
|
||||||
|
return player, None, None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppIconColumn:
|
||||||
|
"""P5-2: the editor's optional icon column (package=name|icon)."""
|
||||||
|
|
||||||
|
async def test_two_column_line_stores_the_icon(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
|
||||||
|
|
||||||
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
||||||
|
|
||||||
|
async def test_single_column_map_stays_valid(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
|
||||||
|
|
||||||
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
|
||||||
|
|
||||||
|
async def test_prefill_round_trips_the_icon_column(self):
|
||||||
|
module = _form_module()
|
||||||
|
|
||||||
|
# the serializer writes the second column through on prefill
|
||||||
|
text = module._app_map_to_text(
|
||||||
|
{"ar.tvplayer.tv": "TiviMate"},
|
||||||
|
{"ar.tvplayer.tv": "http://i.local/t.png"},
|
||||||
|
)
|
||||||
|
assert text == "ar.tvplayer.tv=TiviMate|http://i.local/t.png"
|
||||||
|
|
||||||
|
# and the parser round-trips it back
|
||||||
|
icons: dict[str, str] = {}
|
||||||
|
app_map = module._app_map_from_text(text, icons)
|
||||||
|
assert app_map == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
assert icons == {"ar.tvplayer.tv": "http://i.local/t.png"}
|
||||||
|
|
||||||
|
async def test_malformed_icon_invalidates_the_row(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options={}, title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_app_map"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelEntryOptions:
|
||||||
|
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||||
|
|
||||||
|
def _options_flow(self, tmp_path, options):
|
||||||
|
from types import SimpleNamespace as DefaultEntry
|
||||||
|
|
||||||
|
module = _form_module()
|
||||||
|
entry = DefaultEntry(options=dict(options) if options else {})
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
flow._options = dict(options) if options else {}
|
||||||
|
return module, flow
|
||||||
|
|
||||||
|
async def test_digit_delay_and_send_enter_store(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
|
||||||
|
|
||||||
|
assert flow._options["channel_digit_delay"] == 0.3
|
||||||
|
assert flow._options["channel_send_enter"] is True
|
||||||
|
|
||||||
|
async def test_digit_delay_out_of_window_rejects(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
async def test_digit_delay_not_a_number_rejects(self, tmp_path):
|
||||||
|
module, flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||||
|
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"channel_digit_delay": "slow"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyUniqueIdMigration:
|
||||||
|
"""P5-1: one-time legacy androidtv-entity-id unique_id migration."""
|
||||||
|
|
||||||
|
async def test_migrates_to_entry_id_without_protocol(self, tmp_path):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield", # legacy
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert entry.unique_id == "cfgid123" # config-entry id fallback
|
||||||
|
|
||||||
|
async def test_migrates_to_protocol_mac_when_resolvable(self, tmp_path):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield",
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
async def async_protocol_device_info(self):
|
||||||
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
entry.runtime_data = _Manager()
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # MAC preferred
|
||||||
|
|
||||||
|
async def test_mac_collision_falls_back_to_entry_id(self, tmp_path):
|
||||||
|
"""t26 F4: an existing entry owning the target MAC blocks the move."""
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield",
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
async def async_protocol_device_info(self):
|
||||||
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
entry.runtime_data = _Manager()
|
||||||
|
|
||||||
|
class _Entries:
|
||||||
|
entries = [
|
||||||
|
SimpleNamespace(entry_id="cfgid999", unique_id="aa:bb:cc:dd:ee:ff")
|
||||||
|
]
|
||||||
|
updating_entry_calls: list = []
|
||||||
|
|
||||||
|
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", "unique_id"):
|
||||||
|
setattr(entry, key, value)
|
||||||
|
|
||||||
|
entries = _Entries()
|
||||||
|
entries.hass = FakeHass(str(tmp_path))
|
||||||
|
hass = entries.hass
|
||||||
|
hass.config_entries = entries
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
# collision → the config-entry id is used instead
|
||||||
|
assert entry.unique_id == "cfgid123"
|
||||||
|
|
||||||
|
async def test_free_mac_is_taken_without_collision(self, tmp_path):
|
||||||
|
"""t26 F4: no other entry holds the MAC → the MAC is used."""
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="media_player.shield",
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
async def async_protocol_device_info(self):
|
||||||
|
return "Shield TV", "aa:bb:cc:dd:ee:ff"
|
||||||
|
|
||||||
|
entry.runtime_data = _Manager()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
hass.config_entries.entries = [
|
||||||
|
SimpleNamespace(entry_id="cfgid999", unique_id="other:mac") # different MAC
|
||||||
|
]
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert entry.unique_id == "aa:bb:cc:dd:ee:ff" # free MAC taken
|
||||||
|
|
||||||
|
async def test_never_runs_twice_for_a_migrated_entry(self, tmp_path):
|
||||||
|
"""The legacy pattern no longer matches after the one-time move."""
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
entity_stublib.load_entity_modules()
|
||||||
|
modules = sys.modules["nsh.media_player"]
|
||||||
|
from types import SimpleNamespace as MigrationEntry
|
||||||
|
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
entry = MigrationEntry(
|
||||||
|
entry_id="cfgid123",
|
||||||
|
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||||
|
options={},
|
||||||
|
title="Shield",
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff", # already migrated
|
||||||
|
runtime_data=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await modules._migrate_legacy_unique_id(hass, entry)
|
||||||
|
|
||||||
|
assert hass.config_entries.updating_entry_calls == [] # untouched
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeroconfDiscovery:
|
||||||
|
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
|
||||||
|
|
||||||
|
def _discovery(self, host="192.168.1.50", props=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
ip_address=host, host=host, properties=props or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_discovery_prefills_the_user_step(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(self._discovery())
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "user"
|
||||||
|
# pre-fill landed: the discovered host is recorded for the schema
|
||||||
|
assert flow._discovered_host == "192.168.1.50"
|
||||||
|
assert result["data_schema"] is not None
|
||||||
|
|
||||||
|
async def test_discovery_without_address_aborts(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(
|
||||||
|
SimpleNamespace(ip_address=None, host=None, properties={})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "zeroconf_no_host"}
|
||||||
|
|
||||||
|
async def test_known_mac_ip_change_updates_entry_hosts(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"},
|
||||||
|
entry_id="e1",
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(
|
||||||
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
# the hosts moved to the discovery address
|
||||||
|
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||||
|
assert entry.options["adb_host"] == "192.168.1.50"
|
||||||
|
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||||
|
|
||||||
|
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
# both hosts already hold the discovered address: nothing changes
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
|
options={
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "192.168.1.50",
|
||||||
|
"adb_host": "192.168.1.50",
|
||||||
|
},
|
||||||
|
entry_id="e1",
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(
|
||||||
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
||||||
|
assert hass.config_entries.reloaded_entry_ids == []
|
||||||
|
|
||||||
|
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path):
|
||||||
|
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
|
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||||
|
entry_id="e1",
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(
|
||||||
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
# only the adb host was filled (self-heal), protocol already current:
|
||||||
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||||
|
assert update["adb_host"] == "192.168.1.50"
|
||||||
|
assert update["protocol_host"] == "192.168.1.50"
|
||||||
|
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||||
|
|
||||||
|
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
|
||||||
|
"""F2: an explicit ADB override survives a protocol-host IP change."""
|
||||||
|
module = _form_module()
|
||||||
|
flow = module.NvidiaShieldConfigFlow()
|
||||||
|
hass = FakeHass(str(tmp_path))
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
entry = SimpleNamespace(
|
||||||
|
unique_id="aa:bb:cc:dd:ee:ff",
|
||||||
|
options={
|
||||||
|
CONF_PROTOCOL_ENABLED: True,
|
||||||
|
CONF_PROTOCOL_HOST: "10.0.0.1", # stale (IP moved)
|
||||||
|
"adb_host": "10.0.0.9", # deliberate ADB override
|
||||||
|
},
|
||||||
|
entry_id="e1",
|
||||||
|
title="Shield",
|
||||||
|
)
|
||||||
|
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||||
|
|
||||||
|
result = await flow.async_step_zeroconf(
|
||||||
|
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"type": "abort", "reason": "already_configured"}
|
||||||
|
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||||
|
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
||||||
|
assert update["adb_host"] == "10.0.0.9" # override untouched
|
||||||
|
assert hass.config_entries.reloaded_entry_ids == ["e1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppsMapOptionsEditor:
|
||||||
|
"""P4-1b: the apps name/icon map editor in the options flow."""
|
||||||
|
|
||||||
|
def _options_flow(self, tmp_path, options):
|
||||||
|
module = _form_module()
|
||||||
|
entry = SimpleNamespace(options=dict(options), title="Shield")
|
||||||
|
flow = module.NvidiaShieldOptionsFlow(entry)
|
||||||
|
flow.hass = FakeHass(str(tmp_path))
|
||||||
|
return flow
|
||||||
|
|
||||||
|
async def test_map_text_stored_as_dict(self, tmp_path):
|
||||||
|
flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
result = await flow.async_step_init(
|
||||||
|
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert flow._options["app_name_map"] == {
|
||||||
|
"ar.tvplayer.tv": "TiviMate",
|
||||||
|
"com.plex.hdx": "Plex",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_invalid_map_shows_form_error(self, tmp_path):
|
||||||
|
flow = self._options_flow(tmp_path, options={})
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"app_name_map": "no equals sign here"})
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_app_map"}
|
||||||
|
|
||||||
|
async def test_blank_map_clears_the_entries(self, tmp_path):
|
||||||
|
flow = self._options_flow(
|
||||||
|
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await flow.async_step_init({"app_name_map": " "})
|
||||||
|
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert flow._options["app_name_map"] == {}
|
||||||
|
|
||||||
|
async def test_existing_map_prefills_the_text_field(self, tmp_path):
|
||||||
|
flow = self._options_flow(
|
||||||
|
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# first visit: the form carries the serialized current map as the
|
||||||
|
# field default
|
||||||
|
result = await flow.async_step_init(None)
|
||||||
|
|
||||||
|
assert result["type"] == "form"
|
||||||
|
# the editor carried the existing map through the defaults path
|
||||||
|
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppNameMapResolution:
|
||||||
|
"""app_name resolves through the map after the entity/push tiers."""
|
||||||
|
|
||||||
|
async def test_mapped_package_resolves_the_friendly_name(self):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
modules = entity_stublib.load_entity_modules()
|
||||||
|
player, _s, _i = _cmd_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
player._androidtv_entity = "" # standalone
|
||||||
|
|
||||||
|
manager = player._manager
|
||||||
|
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||||
|
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||||
|
|
||||||
|
assert player.app_name == "TiviMate"
|
||||||
|
assert player.app_id == "ar.tvplayer.tv" # app_id stays raw
|
||||||
|
|
||||||
|
async def test_unmapped_package_stays_raw(self):
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
modules = entity_stublib.load_entity_modules()
|
||||||
|
player, _s, _i = _cmd_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||||
|
player._androidtv_entity = ""
|
||||||
|
|
||||||
|
manager = player._manager
|
||||||
|
manager._on_current_app("com.google.android.youtube.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert player.app_name == "com.google.android.youtube.tv"
|
||||||
|
|
||||||
|
async def test_entity_tier_serves_when_no_higher_tier_matches(self):
|
||||||
|
"""P5-1: the entity tier is below map/manifest — it serves when the
|
||||||
|
package has no explicit override and no manifest label yet."""
|
||||||
|
from types import SimpleNamespace as SimpleNS
|
||||||
|
|
||||||
|
import entity_stublib
|
||||||
|
|
||||||
|
modules = entity_stublib.load_entity_modules()
|
||||||
|
player, _s, _i = _cmd_player(modules)
|
||||||
|
player._entry.options["app_name_map"] = {} # no override
|
||||||
|
player._androidtv_entity = "media_player.atv"
|
||||||
|
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
||||||
|
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
||||||
|
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||||
|
|
||||||
|
player._manager._on_current_app("ar.tvplayer.tv")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# no cached label yet → falls through to the entity tier
|
||||||
|
assert player.app_name == "ATV Tivi"
|
||||||
Reference in new issue
Block a user