Compare commits
111
Commits
v0.1.1
...
v2026.10.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d426945b | ||
|
|
c20146b08b | ||
|
|
6087566336 | ||
|
|
8f40b6435c | ||
|
|
c1a82a2fa5 | ||
|
|
782d749b4a | ||
|
|
d004e73d99 | ||
|
|
e77bc12d81 | ||
|
|
80ddff313a | ||
|
|
297f8a21a7 | ||
|
|
a6eeae86e9 | ||
|
|
2e70587871 | ||
|
|
194a97ce34 | ||
|
|
7c12d96070 | ||
|
|
a5254b4740 | ||
|
|
383ec5ada5 | ||
|
|
e1a0fc3039 | ||
|
|
086c089cc1 | ||
|
|
bcb8b962e7 | ||
|
|
3e0208789a | ||
|
|
79faa32b68 | ||
|
|
e51545262e | ||
|
|
9b3142874f | ||
|
|
27715f6932 | ||
|
|
6c925c5241 | ||
|
|
3587747c72 | ||
|
|
2e63135745 | ||
|
|
67174b8536 | ||
|
|
b442440ccf | ||
|
|
697e3ba307 | ||
|
|
f863496ce5 | ||
|
|
07cb73fbb7 | ||
|
|
0ecee66953 | ||
|
|
a3b397920e | ||
|
|
3d026ae60e | ||
|
|
4661e52041 | ||
|
|
c57a2e2ff2 | ||
|
|
2e6dfd8981 | ||
|
|
cd0e634a76 | ||
|
|
80e9a691bc | ||
|
|
17648f43c9 | ||
|
|
f03ef1f531 | ||
|
|
b37a14b920 | ||
|
|
c44c2c2170 | ||
|
|
326f02cb59 | ||
|
|
034cb5c83d | ||
|
|
4222027602 | ||
|
|
f4d750ff92 | ||
|
|
61c7ac99a3 | ||
|
|
1dbb695cec | ||
|
|
8b4fea9a5c | ||
|
|
ce90f59f8e | ||
|
|
3b8e97fe5b | ||
|
|
392553ee26 | ||
|
|
f58be9ed05 | ||
|
|
bc457baae7 | ||
|
|
1f0a4b2021 | ||
|
|
aa4ee7ed62 | ||
|
|
bb2f787fcd | ||
|
|
8885ddd5b5 | ||
|
|
f1e49a661b | ||
|
|
34d09e9f70 | ||
|
|
d35b61078d | ||
|
|
a65c893339 | ||
|
|
5f8c3290cc | ||
|
|
8462ad494c | ||
|
|
b451638c65 | ||
|
|
f071d540bd | ||
|
|
728b7ccbf9 | ||
|
|
61aca9d413 | ||
|
|
906126b0da | ||
|
|
d57c4eaa40 | ||
|
|
b5fc0dacf4 | ||
|
|
e37a3be75f | ||
|
|
a4e7a285d4 | ||
|
|
9a1e1300dd | ||
|
|
49cf402bbe | ||
|
|
27cc69b1d3 | ||
|
|
d07820cde8 | ||
|
|
75fff11d04 | ||
|
|
9c409b3805 | ||
|
|
24981d84a8 | ||
|
|
48c30463ec | ||
|
|
0da84a5a6e | ||
|
|
93b766ae97 | ||
|
|
0a7dd887d7 | ||
|
|
63f76f5328 | ||
|
|
ec2db2ae58 | ||
|
|
4779c20407 | ||
|
|
2ade5091c4 | ||
|
|
4269e233b8 | ||
|
|
37164b6f24 | ||
|
|
a72cf57494 | ||
|
|
a1e671ac6a | ||
|
|
b91a4242ba | ||
|
|
f6a37bdcd5 | ||
|
|
db8712db1a | ||
|
|
8f27618e3d | ||
|
|
4702c9534c | ||
|
|
ce82127a40 | ||
|
|
6b4227cb40 | ||
|
|
d3afda0a25 | ||
|
|
359b96ef9c | ||
|
|
a2f1948a88 | ||
|
|
f829ef7a40 | ||
|
|
b706c031ea | ||
|
|
304e6dc3ec | ||
|
|
6622cc04b4 | ||
|
|
9da7cb8b22 | ||
|
|
37df5732b7 | ||
|
|
afc5f0a1d0 |
No files matched your search
@@ -0,0 +1,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,101 +1,211 @@
|
||||
# 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
|
||||
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
|
||||
## What we're building (the fork's goals)
|
||||
|
||||
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
|
||||
style MZ fill:#1a73e8,color:#fff
|
||||
style ADB fill:#444,color:#fff
|
||||
style JF fill:#00a4dc,color:#fff
|
||||
style ATV fill:#444,color:#fff
|
||||
style PWR fill:#444,color:#fff
|
||||
1. **Event-driven ADB media session tracking as the primary playback state source** —
|
||||
`dumpsys media_session` over a persistent connection, not screen capture, not
|
||||
10-second polling.
|
||||
2. **Fallback state for apps without media sessions** — e.g. TiviMate publishes
|
||||
none at all; foreground-app detection (`mResumedActivity`) + last-known-state
|
||||
+ 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.
|
||||
|
||||
Phases 0–4 with files, risks, and definitions of done live in
|
||||
[`docs/MVP_PLAN.md`](docs/MVP_PLAN.md).
|
||||
|
||||
## Current features (inherited from upstream 0.1.x)
|
||||
|
||||
- **ADB Media Session** — now-playing from any app that publishes a session
|
||||
(Spotify, Netflix, Plex, Wholphin, …)
|
||||
- **Jellyfin rich metadata** — title, series/episode, position, stream details
|
||||
(resolution, codec, HDR, audio format) via the HA Jellyfin integration's cache
|
||||
- **Power monitoring** — smart plug for reliable on/off with auto-discovered
|
||||
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`.
|
||||
|
||||
### YAML seeding (optional, seed-only)
|
||||
|
||||
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
|
||||
interactive setup flow — nothing is created without the user walking the
|
||||
standard flow, and pairing stays interactive exactly as with UI setup:
|
||||
|
||||
```yaml
|
||||
nvidia_shield:
|
||||
- name: Living Room
|
||||
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
|
||||
adb_port: 5555
|
||||
```
|
||||
|
||||
## Features
|
||||
`protocol_host` is a prefill hint only (the PIN pairing still happens in
|
||||
the flow). Blocks without protocol/adb hosts are ignored with a warning.
|
||||
Duplicate guards: a block matching an existing entry's host (or its
|
||||
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||
host. Removing the YAML block creates or deletes nothing — entries live
|
||||
in the storage registry and require user consent to remove. Deleting/re-
|
||||
adding is the only way to move an imported entry between hosts; the UI is
|
||||
the contractual path (YAML is the seed, the flow is the truth).
|
||||
|
||||
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin
|
||||
- **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)
|
||||
- **Receiver Coordination** -- Links to a receiver entity and input name
|
||||
- **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
|
||||
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity
|
||||
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration
|
||||
### 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
|
||||
|
||||
### 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**.
|
||||
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**.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||
2. Select the Android TV Remote entity for your Shield
|
||||
3. Enable ADB if you want now-playing from all apps
|
||||
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield
|
||||
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
||||
5. Optionally configure Jellyfin, receiver, power switch
|
||||
|
||||
### 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.
|
||||
|
||||
### Options
|
||||
|
||||
- **Jellyfin Media Player** -- Auto-detected or manually selected
|
||||
- **Receiver + Input** -- For Media Zone coordination
|
||||
- **Power Switch** -- Smart plug entity
|
||||
- **ADB Host/Port** -- Override auto-detection if needed
|
||||
- **Jellyfin Media Player** — auto-detected or manually selected
|
||||
- **Receiver + Input** — for media-zone coordination
|
||||
- **Power Switch** — smart plug entity
|
||||
- **ADB Host/Port** — override auto-detection if needed
|
||||
|
||||
## Entities
|
||||
|
||||
| Entity | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| Media Player | `media_player` | Main entity with playback controls and metadata |
|
||||
| Current App | `sensor` | Currently running app |
|
||||
| Now Playing | `sensor` | Formatted now-playing string |
|
||||
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
|
||||
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
|
||||
| Pair ADB | `button` | Initiate or re-pair ADB connection |
|
||||
|
||||
## Development
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Part of the Media Zone Ecosystem
|
||||
|
||||
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
|
||||
- **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
|
||||
`.gitea/workflows/release.yml`). Auth uses the job-provided `GITEA_TOKEN` —
|
||||
no extra secrets required.
|
||||
- **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`).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,16 +1,150 @@
|
||||
"""The Nvidia Shield integration."""
|
||||
"""The Nvidia Shield integration.
|
||||
|
||||
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
|
||||
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
|
||||
socket and all ADB-derived state live there, not on the entities. Entities are
|
||||
thin callback-views over the manager's StateStore.
|
||||
|
||||
ADB is optional: a failed/absent ADB connection never blocks setup (no
|
||||
ConfigEntryNotReady) — the manager retries in the background, or stays
|
||||
passive until the user pairs via the Pair ADB button.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .adb_session import AdbMediaSessionPoller
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
SERVICE_ADB_COMMAND,
|
||||
)
|
||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
|
||||
|
||||
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
|
||||
interactive SOURCE_IMPORT config flow — nothing is created without the
|
||||
user walking the flow, and pairing stays interactive exactly as for UI
|
||||
setup. Removing the YAML block creates OR deletes nothing. UI-configured
|
||||
entries are never re-seeded (host/MAC duplicate guards).
|
||||
"""
|
||||
domain_config = (config or {}).get(DOMAIN)
|
||||
for block in _yaml_seed_blocks(domain_config or []):
|
||||
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST):
|
||||
_LOGGER.warning(
|
||||
"nvidia_shield YAML block without protocol/adb host ignored: %s",
|
||||
block.get("name", "<unnamed>"),
|
||||
)
|
||||
continue
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=block,
|
||||
)
|
||||
)
|
||||
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
|
||||
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADB_COMMAND,
|
||||
_async_adb_command_service,
|
||||
schema=_ADB_COMMAND_SCHEMA,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _yaml_seed_blocks(domain_config) -> list[dict]:
|
||||
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
|
||||
blocks: list[dict] = []
|
||||
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
|
||||
for block in raw_list:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
name = block.get("name") or DEFAULT_NAME
|
||||
# t32 F4 (review rider): the seed honors the block's shape — a
|
||||
# protocol-only block does NOT force ADB on; ADB comes only when the
|
||||
# block carries an adb host (interactive cosmetics stay in the flow).
|
||||
seed = {
|
||||
"name": name,
|
||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
|
||||
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||
# protocol fields are prefill hints ONLY — pairing stays interactive
|
||||
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
|
||||
}
|
||||
if block.get("unique_id"):
|
||||
seed["unique_id"] = str(block["unique_id"])
|
||||
blocks.append(seed)
|
||||
return blocks
|
||||
|
||||
|
||||
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Poller factory when ADB is configured AND usable today, else None.
|
||||
|
||||
Returning None leaves the manager passive: the entry still sets up, the
|
||||
entities simply see no ADB state. The Pair ADB button / reconfigure
|
||||
triggers a reload that re-evaluates this.
|
||||
"""
|
||||
if not entry.options.get(CONF_ADB_ENABLED, False):
|
||||
return None
|
||||
|
||||
adb = resolve_adb_config(hass, entry)
|
||||
if adb is None:
|
||||
_LOGGER.warning(
|
||||
"ADB enabled but no host resolvable for entry %s — continuing "
|
||||
"without ADB state.",
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
# Only auto-connect when a key already exists — an ADB handshake against
|
||||
# an unpaired device is pointless noise (pairing happens via the Pair ADB
|
||||
# button / config flow, untouched).
|
||||
key_path_ready = await hass.async_add_executor_job(
|
||||
os.path.exists, adb.key_path
|
||||
)
|
||||
if not key_path_ready:
|
||||
_LOGGER.info(
|
||||
"ADB key not found for entry %s — skipping auto-connect. "
|
||||
"Use the Pair ADB button or reconfigure the integration.",
|
||||
entry.title,
|
||||
)
|
||||
return None
|
||||
|
||||
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
|
||||
# host/port resolve from entry.options AT CALL TIME, so the poller built
|
||||
# after a zeroconf IP change targets the NEW address without a reload
|
||||
# (the setup-time closure pinned the old address). A key-path change
|
||||
# still needs the Pair ADB button / reconfigure reload, by design.
|
||||
def _factory():
|
||||
adb_now = resolve_adb_config(hass, entry)
|
||||
if adb_now is None:
|
||||
return None
|
||||
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -18,10 +152,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = entry
|
||||
|
||||
manager = ShieldConnectionManager(
|
||||
hass,
|
||||
entry,
|
||||
poller_factory=await _build_poller_factory(hass, entry),
|
||||
protocol_settings=await _build_protocol_settings(hass, entry),
|
||||
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
|
||||
)
|
||||
entry.runtime_data = manager
|
||||
await manager.async_start()
|
||||
|
||||
# Close the persistent socket on Home Assistant stop as well as unload.
|
||||
async def _close_on_hass_stop(_event) -> None:
|
||||
await manager.async_stop()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
return True
|
||||
|
||||
|
||||
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Manager protocol_settings when the transport is enabled & resolvable.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -30,11 +231,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
if manager is not None:
|
||||
await manager.async_stop()
|
||||
entry.runtime_data = None
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
@@ -1,15 +1,24 @@
|
||||
"""ADB media session reader for NVIDIA Shield.
|
||||
"""ADB session adapter for NVIDIA Shield.
|
||||
|
||||
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
|
||||
`poll()` method that calls wake check + media session in sequence.
|
||||
Since Phase 1 this module is a thin adapter on top of ha_adb_common:
|
||||
|
||||
- ``ensure_adb_key`` (re-exported here for the config flow / Pair ADB button)
|
||||
does the one-time key-material setup — the pairing flow stays untouched.
|
||||
- ``AdbMediaSessionPoller`` remains the protocol-level ADB client used by the
|
||||
ShieldConnectionManager (connection.py) and one-shot pairing connects. The
|
||||
polling loop itself lives in the connection manager, not here.
|
||||
|
||||
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
|
||||
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
|
||||
from .ha_adb_common import (
|
||||
AdbClientBase,
|
||||
MediaSessionInfo, # noqa: F401 (re-export)
|
||||
parse_foreground,
|
||||
)
|
||||
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
|
||||
prefer its media session over stale sessions from other apps.
|
||||
"""
|
||||
return await self.poll_media_sessions(foreground_package)
|
||||
|
||||
async def poll_foreground(self) -> str | None:
|
||||
"""Foreground package via `dumpsys activity activities` (RESEARCH R4).
|
||||
|
||||
The grep keeps the dump tiny; the parser handles the R4 line wrap.
|
||||
"""
|
||||
dump = await self.shell("dumpsys activity activities 2>/dev/null | grep -iE 'resumed'")
|
||||
if not dump:
|
||||
return None
|
||||
return parse_foreground(dump)
|
||||
@@ -5,8 +5,7 @@ import logging
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||
@@ -18,6 +17,7 @@ from .const import (
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import discover_adb_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +36,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Pair ADB"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:link-variant"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
@@ -52,29 +53,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
model="Shield TV",
|
||||
)
|
||||
|
||||
def _resolve_host(self) -> str | None:
|
||||
"""Resolve ADB host from config or Android TV entity."""
|
||||
host = self._entry.options.get(CONF_ADB_HOST)
|
||||
if host:
|
||||
return host
|
||||
|
||||
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
if not atv_entity:
|
||||
return None
|
||||
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(atv_entity)
|
||||
if not entity_entry or not entity_entry.config_entry_id:
|
||||
return None
|
||||
|
||||
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
|
||||
if config_entry:
|
||||
return config_entry.data.get("host")
|
||||
return None
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||
host = self._resolve_host()
|
||||
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)
|
||||
|
||||
if not host:
|
||||
@@ -85,7 +68,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
ensure_adb_key, self.hass.config.config_dir
|
||||
)
|
||||
|
||||
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
|
||||
_LOGGER.info(
|
||||
"Attempting ADB pairing with %s:%d — approve the prompt on the Shield",
|
||||
host,
|
||||
port,
|
||||
)
|
||||
|
||||
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||
if await poller.connect():
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
"""Config flow for Nvidia Shield integration."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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 (
|
||||
ATTR_MEDIA_DAG_INPUT_MAP,
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_APP_ICON_MAP,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
CONF_CHANNEL_SEND_ENTER,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_LOGCAT_EVENTS,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import discover_adb_host, protocol_cert_paths
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,6 +52,34 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
|
||||
return list(input_map.keys()) if isinstance(input_map, dict) else []
|
||||
|
||||
|
||||
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
|
||||
"""A stable unique_id from the atv entity's device MAC (t29 G2).
|
||||
|
||||
The device registry holds CONNECTION_NETWORK_MAC for Android TV
|
||||
entities configured with a host — far stronger than the renameable
|
||||
entity_id. None (→ no unique_id at all, not an entity-derived
|
||||
placeholder) when the entity is unregistered or carries no MAC.
|
||||
"""
|
||||
if not androidtv_entity:
|
||||
return None
|
||||
registry = er.async_get(hass)
|
||||
entity = registry.entities.get(androidtv_entity)
|
||||
device_id = getattr(entity, "device_id", None) if entity else None
|
||||
device = (
|
||||
dr.async_get(hass).async_get(device_id) if device_id else None
|
||||
)
|
||||
if device is None:
|
||||
return None
|
||||
if isinstance(dr, dict): # stub shape guard
|
||||
return None
|
||||
for connection in device.connections:
|
||||
# connections are {connection_type, value} pairs; only a REAL mac
|
||||
# value is usable (an empty placeholder is skipped)
|
||||
if connection[0] == "mac" and connection[1]:
|
||||
return connection[1]
|
||||
return None
|
||||
|
||||
|
||||
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
|
||||
"""Auto-detect the Jellyfin media_player entity for a device."""
|
||||
registry = er.async_get(hass)
|
||||
@@ -69,6 +110,81 @@ def _find_jellyfin_entity(hass, device_name: str) -> str | 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)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||
if "host" in defaults:
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_HOST)] = str
|
||||
return schema_dict
|
||||
|
||||
|
||||
def _build_selectors():
|
||||
"""Build reusable selector instances."""
|
||||
return {
|
||||
@@ -90,26 +206,66 @@ def _build_selectors():
|
||||
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Nvidia Shield."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self):
|
||||
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):
|
||||
"""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 = {}
|
||||
|
||||
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 = (
|
||||
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,
|
||||
)
|
||||
|
||||
# t29 G2: the androidtv entity_id is a renameable reference —
|
||||
# never a unique_id. A sqlite/registry MAC is preferred (the
|
||||
# atv entity's device holds CONNECTION_NETWORK_MAC); without a
|
||||
# resolvable MAC the entry gets NO unique_id (the same
|
||||
# uniqueness-less semantics _async_handle_discovery_without_
|
||||
# unique_id applies to MAC-less discovery) — renames can never
|
||||
# break uniqueness.
|
||||
fallback_unique_id = _unique_id_from_atv_device(
|
||||
self.hass, androidtv_entity
|
||||
)
|
||||
if fallback_unique_id:
|
||||
await self.async_set_unique_id(fallback_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Auto-detect Jellyfin if not manually specified
|
||||
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
|
||||
if not jellyfin:
|
||||
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 "")
|
||||
|
||||
for name in [device_name, atv_name]:
|
||||
@@ -123,7 +279,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._user_data = {
|
||||
"name": user_input.get("name", DEFAULT_NAME),
|
||||
CONF_ANDROIDTV_ENTITY: androidtv_entity,
|
||||
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
|
||||
CONF_JELLYFIN_ENTITY: jellyfin or "",
|
||||
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
|
||||
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
|
||||
@@ -132,34 +288,29 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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_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 self._user_data[CONF_ADB_ENABLED]:
|
||||
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()
|
||||
|
||||
|
||||
sel = _build_selectors()
|
||||
|
||||
schema_dict = {
|
||||
vol.Required("name", default=DEFAULT_NAME): str,
|
||||
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
|
||||
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
|
||||
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
|
||||
defaults = {}
|
||||
if self._discovered_host:
|
||||
defaults = {
|
||||
"name": self._discovered_name or DEFAULT_NAME,
|
||||
"host": self._discovered_host,
|
||||
}
|
||||
|
||||
# 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 = _user_schema_dict(sel, defaults)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -167,12 +318,84 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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:
|
||||
# t29 G3a: the deprecated update+schedule-reload combo is
|
||||
# gone (2026.12 error) — the entry updates ONLY here; the
|
||||
# self-heal rides on the t32 F1 poller factory, which
|
||||
# re-reads entry.options at call time (no reload).
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry, options=options
|
||||
)
|
||||
return self.async_abort(reason="already_configured")
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
# t32 F2 (review rider): MAC-less discovery cannot carry a
|
||||
# unique_id — HA's helper records the uniqueness-less discovery
|
||||
# (the same discovery context can't stack duplicates) and the
|
||||
# host guard keeps a second discovery of an already-configured
|
||||
# host from stacking another prefill.
|
||||
self._async_handle_discovery_without_unique_id()
|
||||
for entry in self._async_current_entries():
|
||||
existing_host = entry.options.get(
|
||||
CONF_PROTOCOL_HOST
|
||||
) or entry.options.get(CONF_ADB_HOST)
|
||||
if existing_host and existing_host == host:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# First-time setup: pre-filled user step
|
||||
return await self.async_step_user()
|
||||
|
||||
def _create_entry(self):
|
||||
"""Create the config entry from stored 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(
|
||||
title=d["name"],
|
||||
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]},
|
||||
data=entry_data,
|
||||
options={
|
||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||
@@ -182,28 +405,185 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
||||
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
||||
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_import(self, data):
|
||||
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
|
||||
|
||||
The import validates the block and routes into the standard user
|
||||
flow — pairing stays interactive, exactly one visit. Duplicate
|
||||
guards: the block's host/MAC-virtual unique_id aborts when an
|
||||
entry already holds it, and a UI-configured entry with the same
|
||||
protocol/adb host is never re-seeded.
|
||||
"""
|
||||
block = dict(data or {})
|
||||
name = block.get("name") or DEFAULT_NAME
|
||||
seed_unique_id = block.get("unique_id")
|
||||
if not seed_unique_id:
|
||||
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||||
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
|
||||
|
||||
# duplicate guards
|
||||
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
|
||||
for existing in self._async_current_entries():
|
||||
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get(
|
||||
CONF_ADB_HOST
|
||||
)
|
||||
if existing.unique_id == seed_unique_id or (
|
||||
yhost and ehost == yhost
|
||||
):
|
||||
_LOGGER.info(
|
||||
"nvidia_shield YAML seed for %r matches an existing "
|
||||
"entry — not re-seeded",
|
||||
name,
|
||||
)
|
||||
return self.async_abort(reason="already_configured")
|
||||
await self.async_set_unique_id(seed_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# route into the standard user flow (single interactive visit);
|
||||
# t32 F4: the prefill honors the block shape — ADB only when the
|
||||
# block actually carries an adb host.
|
||||
self._user_data = {
|
||||
"name": name,
|
||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""),
|
||||
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||
# protocol prefill hints ONLY (pairing stays interactive)
|
||||
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""),
|
||||
}
|
||||
self._discovered_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
|
||||
CONF_ADB_HOST
|
||||
]
|
||||
return await self.async_step_user(None)
|
||||
|
||||
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 + reload + abort via the current API (G3a).
|
||||
|
||||
The deprecated update-entry + schedule-reload boomerang listener is
|
||||
gone (2026.12 treats it as an error); the current config-entries
|
||||
contract is async_update_reload_and_abort with _get_reauth_entry.
|
||||
"""
|
||||
entry = self._reauth_entry or self._get_reauth_entry()
|
||||
self.async_update_reload_and_abort(
|
||||
entry,
|
||||
options_updates={
|
||||
**entry.options,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||||
},
|
||||
)
|
||||
self._reauth_entry = None
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
def _resolve_adb_host(self) -> str | None:
|
||||
"""Resolve ADB host from explicit config or the Android TV entity."""
|
||||
host = self._user_data.get(CONF_ADB_HOST)
|
||||
if host:
|
||||
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
|
||||
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
|
||||
async def async_step_adb_pair(self, user_input=None):
|
||||
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
|
||||
@@ -220,12 +600,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
ensure_adb_key, self.hass.config.config_dir
|
||||
)
|
||||
|
||||
poller = AdbMediaSessionPoller(host, port, key_path)
|
||||
connected = await poller.connect()
|
||||
await poller.disconnect()
|
||||
|
||||
connected = await ShieldConnectionManager.test_connect(
|
||||
host, port, key_path
|
||||
)
|
||||
if connected:
|
||||
return self._create_entry()
|
||||
return await self._async_after_pairing()
|
||||
else:
|
||||
errors["base"] = "adb_failed"
|
||||
|
||||
@@ -238,42 +617,102 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return NvidiaShieldOptionsFlow(config_entry)
|
||||
return NvidiaShieldOptionsFlow()
|
||||
|
||||
|
||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Handle options for Nvidia Shield — single unified page."""
|
||||
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
|
||||
|
||||
def __init__(self, config_entry):
|
||||
self._config_entry = config_entry
|
||||
self._options = dict(config_entry.options)
|
||||
The constructor takes NO arguments: HA assigns ``self.config_entry``
|
||||
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
|
||||
deprecated). Suggested values come through
|
||||
``add_suggested_values_to_schema``, and saving reloads via the
|
||||
OptionsFlowWithReload base — no listener boomerangs.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._options: dict | None = None
|
||||
|
||||
@property
|
||||
def _options_dict(self) -> dict:
|
||||
if self._options is None:
|
||||
if self.config_entry is not None:
|
||||
self._options = dict(self.config_entry.options)
|
||||
else:
|
||||
self._options = {}
|
||||
return self._options
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Single-page options."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._options.update(user_input)
|
||||
return self.async_create_entry(title="", data=self._options)
|
||||
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
|
||||
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_dict.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_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
|
||||
self._options_dict.update(user_input)
|
||||
self._options_dict[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_dict[CONF_APP_ICON_MAP] = icons
|
||||
return self.async_create_entry(title="", data=self._options_dict)
|
||||
|
||||
sel = _build_selectors()
|
||||
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).
|
||||
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
|
||||
|
||||
# Jellyfin
|
||||
jf = self._options.get(CONF_JELLYFIN_ENTITY)
|
||||
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||
if jf:
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
|
||||
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
|
||||
schema_dict[jf_key] = sel["media_player"]
|
||||
|
||||
# Receiver
|
||||
recv = self._options.get(CONF_RECEIVER_ENTITY)
|
||||
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
if recv:
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
schema_dict[recv_key] = sel["receiver"]
|
||||
|
||||
# P5-2: channel digit behavior (the user-validated §7 flow)
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
|
||||
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
|
||||
|
||||
# P5-3: the live events push is opt-in (default off; the user
|
||||
# tests stability on their device and reports)
|
||||
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
|
||||
|
||||
# P4-2a: the raw adb_command service is opt-in (default off)
|
||||
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
|
||||
|
||||
# Receiver input — dropdown from DAG if receiver is set, text otherwise
|
||||
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
|
||||
receiver = self._options.get(CONF_RECEIVER_ENTITY)
|
||||
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
|
||||
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
|
||||
inputs = _get_receiver_inputs(self.hass, receiver)
|
||||
|
||||
if inputs:
|
||||
@@ -286,42 +725,32 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
|
||||
)
|
||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||
if current_input:
|
||||
input_key = vol.Optional(CONF_RECEIVER_INPUT, default=current_input)
|
||||
input_key = vol.Optional(CONF_RECEIVER_INPUT)
|
||||
schema_dict[input_key] = input_sel
|
||||
else:
|
||||
schema_dict[vol.Optional(
|
||||
CONF_RECEIVER_INPUT, default=current_input
|
||||
)] = str
|
||||
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
|
||||
|
||||
# Power
|
||||
pwr = self._options.get(CONF_POWER_ENTITY)
|
||||
pwr = self._options_dict.get(CONF_POWER_ENTITY)
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
if pwr:
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
schema_dict[pwr_key] = sel["power"]
|
||||
|
||||
# ADB options
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_ENABLED,
|
||||
default=self._options.get(CONF_ADB_ENABLED, False),
|
||||
)] = bool
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
|
||||
)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||
|
||||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||||
adb_host = self._options.get(CONF_ADB_HOST, "")
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_HOST,
|
||||
default=adb_host,
|
||||
)] = str
|
||||
schema_dict[vol.Optional(
|
||||
CONF_ADB_PORT,
|
||||
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||
)] = int
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||
|
||||
# t29 G3b: suggested values come from the entry options via the
|
||||
# documented helper — no per-key `default=` in the schema build.
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(schema_dict),
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict), self._options_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_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_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
||||
DEFAULT_ADB_PORT = 5555
|
||||
|
||||
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
|
||||
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
|
||||
# fields expire (B5) instead of clinging to stale values.
|
||||
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
|
||||
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
||||
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
||||
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||
|
||||
DEFAULT_NAME = "Nvidia Shield"
|
||||
|
||||
# Emotiva DAG attribute we read to get available inputs
|
||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||
|
||||
# Known Jellyfin app package IDs
|
||||
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
|
||||
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
|
||||
JELLYFIN_APP_IDS = [
|
||||
"org.jellyfin.androidtv",
|
||||
"org.jellyfin.mobile",
|
||||
"com.github.damontecres.wholphin",
|
||||
]
|
||||
|
||||
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
|
||||
# HA service kind → protocol keycode string.
|
||||
PROTOCOL_MEDIA_KEYS = {
|
||||
"media_play": "KEYCODE_MEDIA_PLAY",
|
||||
"media_pause": "KEYCODE_MEDIA_PAUSE",
|
||||
"media_stop": "KEYCODE_MEDIA_STOP",
|
||||
"media_next_track": "KEYCODE_MEDIA_NEXT",
|
||||
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
|
||||
}
|
||||
PROTOCOL_VOLUME_KEYS = {
|
||||
"volume_up": "KEYCODE_VOLUME_UP",
|
||||
"volume_down": "KEYCODE_VOLUME_DOWN",
|
||||
"volume_mute": "KEYCODE_VOLUME_MUTE",
|
||||
}
|
||||
PROTOCOL_REMOTE_KEYS = {
|
||||
**PROTOCOL_VOLUME_KEYS,
|
||||
"play": "KEYCODE_MEDIA_PLAY",
|
||||
"pause": "KEYCODE_MEDIA_PAUSE",
|
||||
"stop": "KEYCODE_MEDIA_STOP",
|
||||
"up": "KEYCODE_DPAD_UP",
|
||||
"down": "KEYCODE_DPAD_DOWN",
|
||||
"left": "KEYCODE_DPAD_LEFT",
|
||||
"right": "KEYCODE_DPAD_RIGHT",
|
||||
"enter": "KEYCODE_DPAD_CENTER",
|
||||
"home": "KEYCODE_HOME",
|
||||
"back": "KEYCODE_BACK",
|
||||
"menu": "KEYCODE_MENU",
|
||||
"search": "KEYCODE_SEARCH",
|
||||
"power": "KEYCODE_POWER",
|
||||
"channel_up": "KEYCODE_CHANNEL_UP",
|
||||
"channel_down": "KEYCODE_CHANNEL_DOWN",
|
||||
}
|
||||
|
||||
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
|
||||
# (replicates HA core androidtv_remote's verified behavior).
|
||||
# Zeroconf discovery (Phase 4): the Android TV remote service type.
|
||||
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
|
||||
|
||||
# P4-1 apps name/icon map (options flow editor): package → friendly name,
|
||||
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
||||
CONF_APP_NAME_MAP = "app_name_map"
|
||||
|
||||
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
|
||||
# entity_picture when no Jellyfin session picture is live.
|
||||
CONF_APP_ICON_MAP = "app_icon_map"
|
||||
|
||||
# P5-1: device manifest label cache TTL (long — labels change with app
|
||||
# updates, not per poll).
|
||||
APP_LABEL_TTL = 24 * 3600
|
||||
|
||||
# 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,101 @@
|
||||
"""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**"
|
||||
|
||||
# G14: the documented explicit redaction field list — the audit calls out
|
||||
# config fields like the hosts; pairing/key-shaped fields are masked by
|
||||
# async_redact_data and the marker scan adds a second layer.
|
||||
_TO_REDACT = {
|
||||
"pairing_code",
|
||||
"adb_pairing_code",
|
||||
"protocol_pairing_code",
|
||||
"serial_number",
|
||||
"uuid",
|
||||
"adb_host",
|
||||
"protocol_host",
|
||||
"host",
|
||||
}
|
||||
|
||||
|
||||
def _redact(obj):
|
||||
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
|
||||
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
key: (
|
||||
_REDACTED
|
||||
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
|
||||
else _redact(value)
|
||||
)
|
||||
for key, value in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_redact(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _store_snapshot(manager) -> dict[str, dict]:
|
||||
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
|
||||
snapshot = {}
|
||||
for name, (stamp, value) in manager.store.values_stamped().items():
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
value = asdict(value)
|
||||
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
|
||||
return snapshot
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict:
|
||||
"""Entry diagnostics: redacted config + store snapshot + transport health."""
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
payload: dict = {
|
||||
"entry": {
|
||||
"data": dict(getattr(entry, "data", {}) or {}),
|
||||
"options": dict(getattr(entry, "options", {}) or {}),
|
||||
},
|
||||
"store": {},
|
||||
"transport": {},
|
||||
}
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
payload["store"] = manager.store_snapshot()
|
||||
payload["transport"] = {
|
||||
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||
"protocol": {
|
||||
"active": manager.protocol_active,
|
||||
"available": manager.is_available,
|
||||
"is_on": manager.is_on,
|
||||
},
|
||||
}
|
||||
# G14 (t30): the documented helper is the primary redaction path with
|
||||
# an explicit field list; the marker scan above runs defensively after.
|
||||
try:
|
||||
from homeassistant.util.diagnostic import async_redact_data
|
||||
except ImportError:
|
||||
async_redact_data = None
|
||||
if async_redact_data is not None:
|
||||
payload = async_redact_data(payload, _TO_REDACT)
|
||||
return _redact(payload)
|
||||
|
||||
|
||||
__all__ = ["async_get_config_entry_diagnostics"]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Shared ADB utilities for Home Assistant custom integrations."""
|
||||
|
||||
from .adb_client import AdbClientBase
|
||||
from .adb_client import AdbClientBase, parse_wakefulness
|
||||
from .app_label import parse_application_label
|
||||
from .const import (
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
@@ -9,9 +10,16 @@ from .const import (
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||
)
|
||||
from .foreground import parse_foreground
|
||||
from .intent import PlaybackIntent
|
||||
from .keys import ensure_adb_key
|
||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||
from .logcat_events import foreground_from_logcat
|
||||
from .media_session import (
|
||||
MediaSessionInfo,
|
||||
parse_all_media_sessions,
|
||||
parse_audio_playback,
|
||||
parse_media_session,
|
||||
)
|
||||
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
|
||||
|
||||
__all__ = [
|
||||
@@ -26,6 +34,11 @@ __all__ = [
|
||||
"PlaybackIntent",
|
||||
"ensure_adb_key",
|
||||
"parse_all_media_sessions",
|
||||
"parse_application_label",
|
||||
"parse_audio_playback",
|
||||
"parse_foreground",
|
||||
"foreground_from_logcat",
|
||||
"parse_wakefulness",
|
||||
"parse_media_session",
|
||||
"running_apps_from_sessions",
|
||||
"running_apps_from_tasks",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
Subclasses add device-specific polling (media sessions, wake state, etc.).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
|
||||
|
||||
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
|
||||
|
||||
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
|
||||
|
||||
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
|
||||
# Anything else is "unknown" — never silently reported as awake or asleep.
|
||||
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
|
||||
|
||||
|
||||
def parse_wakefulness(text: str | None) -> bool | None:
|
||||
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
|
||||
|
||||
Exact-value parse, no substring matching: only the ``mWakefulness=``
|
||||
assignment counts — ``mWakefulnessChanging=false`` and
|
||||
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
|
||||
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
|
||||
as "unknown", never as awake or asleep.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
|
||||
if match:
|
||||
value = match.group(1)
|
||||
if value == "Awake":
|
||||
return True
|
||||
if value in _WAKEFULNESS_ASLEEP_VALUES:
|
||||
return False
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class AdbClientBase:
|
||||
"""Base ADB client with connection management and common commands."""
|
||||
@@ -43,12 +72,28 @@ class AdbClientBase:
|
||||
|
||||
# Media session state
|
||||
self._last_info: MediaSessionInfo | None = None
|
||||
self._last_dump: str | None = None
|
||||
self._all_sessions: list[MediaSessionInfo] = []
|
||||
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
"""Public connection config: {host, port, key_path} (t27 F-NEW3).
|
||||
|
||||
Replaces the private-attr reach-ins for secondary streams — callers
|
||||
(the dedicated logcat stream) share the signer material without
|
||||
touching the poller's socket or its private attributes.
|
||||
"""
|
||||
return {
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"key_path": self._key_path,
|
||||
}
|
||||
|
||||
@property
|
||||
def last_info(self) -> MediaSessionInfo | None:
|
||||
return self._last_info
|
||||
@@ -60,6 +105,7 @@ class AdbClientBase:
|
||||
def clear_info(self) -> None:
|
||||
"""Clear the cached media session info."""
|
||||
self._last_info = None
|
||||
self._last_dump = None
|
||||
self._all_sessions = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
@@ -90,17 +136,14 @@ class AdbClientBase:
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the device."""
|
||||
if self._device:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._device.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._device = None
|
||||
self._connected = False
|
||||
|
||||
async def shell(self, cmd: str) -> str | None:
|
||||
"""Run a shell command, reconnecting if needed."""
|
||||
if not self._connected or not self._device:
|
||||
if not await self.connect():
|
||||
if (not self._connected or not self._device) and not await self.connect():
|
||||
return None
|
||||
try:
|
||||
return await self._device.shell(cmd)
|
||||
@@ -135,6 +178,24 @@ class AdbClientBase:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def send_digit_keyevent(self, digit: str) -> bool:
|
||||
"""Send a channel digit via `input keyevent` (KEYCODE_0..9 = 7..16)."""
|
||||
if not digit.isdigit() or len(digit) != 1:
|
||||
_LOGGER.warning("Not a single digit: %s", digit)
|
||||
return False
|
||||
result = await self.shell(f"input keyevent {7 + int(digit)}")
|
||||
if result is not None:
|
||||
_LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit)
|
||||
return result is not None
|
||||
|
||||
async def send_enter_keyevent(self) -> bool:
|
||||
"""Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER
|
||||
fallback for the §7 channel sequence."""
|
||||
result = await self.shell("input keyevent 66")
|
||||
if result is not None:
|
||||
_LOGGER.debug("ADB ENTER keyevent (66)")
|
||||
return result is not None
|
||||
|
||||
async def bring_to_foreground(self, package: str) -> bool:
|
||||
"""Bring an app to the foreground using monkey command."""
|
||||
result = await self.shell(
|
||||
@@ -145,27 +206,60 @@ class AdbClientBase:
|
||||
return result is not None
|
||||
|
||||
async def is_awake(self) -> bool | None:
|
||||
"""Check if the device screen is on / awake via ADB."""
|
||||
"""Check if the device screen is on / awake via ADB.
|
||||
|
||||
Exact-value mWakefulness= parse (R5); None means "unknown".
|
||||
"""
|
||||
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
|
||||
if result:
|
||||
return "Awake" in result
|
||||
return None
|
||||
return parse_wakefulness(result)
|
||||
|
||||
# -- Media session polling --
|
||||
|
||||
async def poll_media_sessions(
|
||||
self, foreground_package: str | None = None, head_lines: int = 200
|
||||
self, foreground_package: str | None = None, head_lines: int | None = 200
|
||||
) -> MediaSessionInfo | None:
|
||||
"""Query media sessions. Stores all sessions and returns the best match."""
|
||||
dump = await self.shell(
|
||||
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
|
||||
)
|
||||
"""Query media sessions. Stores all sessions and returns the best match.
|
||||
|
||||
Args:
|
||||
foreground_package: Prefer the session of the foreground app.
|
||||
head_lines: Truncate the dump to N lines (the historical default
|
||||
of 200). Pass None for the full dump — the ShieldConnection
|
||||
manager does so, since the cut silently drops sessions below
|
||||
it (plan blocker B4).
|
||||
"""
|
||||
command = "dumpsys media_session 2>/dev/null"
|
||||
if head_lines is not None:
|
||||
command += f" | head -{head_lines}"
|
||||
dump = await self.shell(command)
|
||||
self._last_dump = dump if dump is not None else None
|
||||
if dump is None:
|
||||
return None
|
||||
self._all_sessions = parse_all_media_sessions(dump)
|
||||
self._last_info = parse_media_session(dump, foreground_package)
|
||||
return self._last_info
|
||||
|
||||
@property
|
||||
def last_dump(self) -> str | None:
|
||||
"""Raw output of the last media-session query (or None if it failed).
|
||||
|
||||
Shared consumers (the ShieldConnectionManager) parse the "Audio
|
||||
playback" section from this same dump — no extra round trip.
|
||||
"""
|
||||
return self._last_dump
|
||||
|
||||
# -- Health check --
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Verify the connection is actually usable with a cheap shell round trip.
|
||||
|
||||
Returns True only when the device answers the trivial ``echo ok`` with
|
||||
the expected body — a stronger signal than the ``connected`` flag,
|
||||
which can lag behind reality (the watchdog uses this to fire a
|
||||
reconnect instead of trusting a dead socket).
|
||||
"""
|
||||
result = await self.shell("echo ok")
|
||||
return result is not None and result.strip() == "ok"
|
||||
|
||||
# -- Running apps (recent visible tasks) --
|
||||
|
||||
async def poll_running_apps(self) -> list[dict]:
|
||||
@@ -182,12 +276,11 @@ class AdbClientBase:
|
||||
if not result:
|
||||
return []
|
||||
|
||||
import re
|
||||
apps = []
|
||||
seen = set()
|
||||
|
||||
# System packages to exclude
|
||||
_EXCLUDED = {
|
||||
excluded = {
|
||||
"com.android.systemui",
|
||||
"com.android.tv.settings",
|
||||
"com.nvidia.diagtools",
|
||||
@@ -206,7 +299,7 @@ class AdbClientBase:
|
||||
match = re.search(r"I=(\S+)/", stripped)
|
||||
if match:
|
||||
pkg = match.group(1)
|
||||
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
|
||||
if pkg not in seen and pkg not in excluded and not pkg.startswith("."):
|
||||
seen.add(pkg)
|
||||
apps.append({"package": pkg})
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""App friendly-name resolution from the package manifest (P5-1).
|
||||
|
||||
Parses `pm dump <package>` output for the ``application-label`` line — the
|
||||
default-locale variant; localized suffixed variants
|
||||
(``application-label-<lang>:``) are skipped, and multi-label blocks across
|
||||
locales are tolerated (first default-locale label wins), mirroring the
|
||||
tolerant pattern of the androidtv-adb core helper.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_APPLICATION_LABEL_RE = re.compile(
|
||||
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
|
||||
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
|
||||
)
|
||||
|
||||
|
||||
def parse_application_label(dump: str) -> str | None:
|
||||
"""The package's default-locale application label, or None.
|
||||
|
||||
Returns None on empty/failed dumps too — callers fall through to the
|
||||
next name tier.
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
for match in _APPLICATION_LABEL_RE.finditer(dump):
|
||||
label = (match.group(1) or match.group(2) or "").strip()
|
||||
if label and label not in ("null", ""):
|
||||
return label
|
||||
return None
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
|
||||
|
||||
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
|
||||
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
|
||||
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
|
||||
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
|
||||
|
||||
Multi-line ActivityRecord wraps are handled by joining the block until the
|
||||
record closes or a state line arrives (R4 finding 3: match on the joined
|
||||
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_RESUMED_MARKERS = (
|
||||
("mResumedActivity", "mResumedActivity:"),
|
||||
("topResumedActivity", "topResumedActivity:"),
|
||||
("ResumedActivity", "ResumedActivity:"),
|
||||
("Resumed", "Resumed:"),
|
||||
)
|
||||
|
||||
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
|
||||
# anything up to the first `/` (or closing brace when the activity is bare).
|
||||
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
|
||||
|
||||
|
||||
def _marker_kind(stripped: str) -> str | None:
|
||||
for kind, marker in _RESUMED_MARKERS:
|
||||
if stripped.startswith(marker):
|
||||
return kind
|
||||
return None
|
||||
|
||||
|
||||
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
|
||||
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
|
||||
blocks: list[tuple[str, str]] = []
|
||||
kind: str | None = None
|
||||
buf = ""
|
||||
for raw in dump.split("\n"):
|
||||
stripped = raw.strip()
|
||||
marker = _marker_kind(stripped)
|
||||
if marker is not None:
|
||||
if buf:
|
||||
blocks.append((kind, buf))
|
||||
kind, buf = marker, stripped
|
||||
continue
|
||||
if kind is None:
|
||||
continue
|
||||
# Continuation of a wrapped ActivityRecord: join until the record
|
||||
# closes (brace) or the state line completes the block (R4 finding 3).
|
||||
buf += " " + stripped
|
||||
if "}" in stripped or "state=" in stripped:
|
||||
blocks.append((kind, buf))
|
||||
kind, buf = None, ""
|
||||
if buf:
|
||||
blocks.append((kind, buf))
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_package(text: str) -> str | None:
|
||||
match = _PACKAGE_RE.search(text)
|
||||
if match is None:
|
||||
return None
|
||||
package = match.group(1) or match.group(2)
|
||||
if not package or package in ("null", "Window{"):
|
||||
return None
|
||||
return package
|
||||
|
||||
|
||||
def parse_foreground(dump: str) -> str | None:
|
||||
"""Foreground package: mResumedActivity primary, topResumed fallback.
|
||||
|
||||
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
|
||||
callers must not fall back to window-focus parsing (R4 finding 2).
|
||||
"""
|
||||
if not dump:
|
||||
return None
|
||||
blocks = _resumed_blocks(dump)
|
||||
for kind, text in blocks:
|
||||
if kind == "topResumedActivity":
|
||||
continue
|
||||
package = _extract_package(text)
|
||||
if package:
|
||||
return package
|
||||
for kind, text in blocks:
|
||||
if kind != "topResumedActivity":
|
||||
continue
|
||||
package = _extract_package(text)
|
||||
if package:
|
||||
return package
|
||||
return None
|
||||
@@ -13,20 +13,34 @@ class PlaybackIntent:
|
||||
|
||||
Attributes:
|
||||
target_state: The MediaPlayerState we expect after the command.
|
||||
command: The service/command name (for retries and logging).
|
||||
command: The command/service name (for retries and logging).
|
||||
created_at: Monotonic timestamp when the intent was created.
|
||||
prior_state: The source state before the intent (for detecting external overrides).
|
||||
retry_count: Number of retries attempted so far.
|
||||
via_adb: True when the command was successfully served by the ADB
|
||||
keyevent path — the retry must then go over ADB too, not back to
|
||||
the remote that did not serve it.
|
||||
adb_command: The ADB keycode command name matching the intent
|
||||
(play/pause/stop/next/previous); set together with via_adb.
|
||||
via_protocol: True when the command was served by the remote-protocol
|
||||
key push (Phase 2) — the retry then goes over the protocol key
|
||||
again instead of the other transports.
|
||||
protocol_key: The protocol keycode string served with via_protocol.
|
||||
"""
|
||||
|
||||
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
|
||||
command: str
|
||||
created_at: float
|
||||
prior_state: str | None = None
|
||||
retry_count: int = 0
|
||||
via_adb: bool = False
|
||||
adb_command: str | None = None
|
||||
via_protocol: bool = False
|
||||
protocol_key: str | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
|
||||
def create(
|
||||
cls, target_state: str, command: str, prior_state: str | None = None
|
||||
) -> "PlaybackIntent":
|
||||
return cls(
|
||||
target_state=target_state,
|
||||
command=command,
|
||||
@@ -45,4 +59,6 @@ class PlaybackIntent:
|
||||
"command": self.command,
|
||||
"retries": self.retry_count,
|
||||
"age_s": self.age_seconds,
|
||||
"via_adb": self.via_adb,
|
||||
"adb_command": self.adb_command,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
|
||||
|
||||
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
|
||||
the ``cmp=package`` part so the connection manager can stamp the foreground
|
||||
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
|
||||
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
|
||||
source) is untouched: the stream never reads playback state.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
|
||||
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
|
||||
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
|
||||
|
||||
|
||||
def start_records(lines: list[str]) -> list[str]:
|
||||
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
|
||||
|
||||
A record opens at a line that matches a log timestamp and mentions
|
||||
ActivityTaskManager + START; subsequent lines that do NOT look like a
|
||||
fresh log record are treated as continuations (the intent JSON can wrap
|
||||
across lines). Anything else closes/open the next record.
|
||||
"""
|
||||
records: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in lines:
|
||||
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
current = [line]
|
||||
elif current and not _LOG_RECORD_RE.match(line):
|
||||
current.append(line) # wrapped intent text
|
||||
else:
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
current = []
|
||||
if current:
|
||||
records.append("\n".join(current))
|
||||
return records
|
||||
|
||||
|
||||
def foreground_from_logcat(lines: list[str]) -> str | None:
|
||||
"""The foreground package from the LAST valid START record, or None.
|
||||
|
||||
Non-START lines are ignored entirely; records whose intent carries no
|
||||
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
|
||||
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
|
||||
tolerated — the user prefix is never part of the cmp value.
|
||||
"""
|
||||
package: str | None = None
|
||||
for record in start_records(lines):
|
||||
match = _CMP_RE.search(record)
|
||||
if not match:
|
||||
continue
|
||||
candidate = match.group(1)
|
||||
if candidate and candidate != "null":
|
||||
package = candidate # last valid START wins
|
||||
return package
|
||||
|
||||
|
||||
def foreground_packages(lines: list[str]) -> list[str]:
|
||||
"""Every distinct valid START package in order (test seam)."""
|
||||
seen: list[str] = []
|
||||
for record in start_records(lines):
|
||||
match = _CMP_RE.search(record)
|
||||
if not match:
|
||||
continue
|
||||
candidate = match.group(1)
|
||||
if candidate and candidate != "null" and candidate not in seen:
|
||||
seen.append(candidate)
|
||||
return seen
|
||||
|
||||
|
||||
__all__ = [
|
||||
"foreground_from_logcat",
|
||||
"foreground_packages",
|
||||
"start_records",
|
||||
]
|
||||
@@ -4,12 +4,20 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
|
||||
information from any app using Android's MediaSession API.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaSessionInfo:
|
||||
"""Parsed media session data from Android."""
|
||||
"""Parsed media session data from Android.
|
||||
|
||||
Playback position fields come from the session's PlaybackState line:
|
||||
``position`` is the playback offset in ms as of ``updated`` — which is a
|
||||
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
|
||||
wallclock value. Use :meth:`position_at_uptime` with the device's current
|
||||
uptime to extrapolate the live position.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
@@ -17,6 +25,56 @@ class MediaSessionInfo:
|
||||
package: str | None = None
|
||||
playing: 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]:
|
||||
@@ -32,6 +90,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
current_pkg: str | None = None
|
||||
in_active = False
|
||||
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
|
||||
|
||||
for line in dump.split("\n"):
|
||||
@@ -47,16 +108,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
current_pkg = pkg[1].strip()
|
||||
in_active = False
|
||||
current_state = 0
|
||||
current_position = None
|
||||
current_speed = None
|
||||
current_updated = None
|
||||
current_meta = None
|
||||
|
||||
if "active=true" in stripped:
|
||||
in_active = True
|
||||
|
||||
if in_active and "state=PlaybackState" in stripped:
|
||||
if "state=3" in stripped:
|
||||
current_state = 3
|
||||
elif "state=2" in stripped:
|
||||
current_state = 2
|
||||
match = _PLAYBACK_STATE_RE.search(stripped)
|
||||
if match:
|
||||
fields = _parse_playback_state(match.group(1))
|
||||
try:
|
||||
current_state = int(fields.get("state", 0))
|
||||
except ValueError:
|
||||
current_state = 0
|
||||
current_position = _parse_float(fields.get("position"))
|
||||
current_speed = _parse_float(fields.get("speed"))
|
||||
current_updated = _parse_int(fields.get("updated"))
|
||||
|
||||
if in_active and "metadata: size=" in stripped and "description=" in stripped:
|
||||
desc_start = stripped.find("description=")
|
||||
@@ -69,6 +139,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
info.package = current_pkg
|
||||
info.playing = current_state == 3
|
||||
info.paused = current_state == 2
|
||||
info.position = current_position
|
||||
info.speed = current_speed
|
||||
info.updated = current_updated
|
||||
|
||||
if len(parts) >= 1 and parts[0] not in ("null", ""):
|
||||
info.title = parts[0]
|
||||
@@ -97,6 +170,34 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
|
||||
return sessions
|
||||
|
||||
|
||||
def parse_audio_playback(dump: str) -> list[str]:
|
||||
"""Parse the "Audio playback (lastly played comes first)" section.
|
||||
|
||||
Returns the package names that recently played audio, in the dump's own
|
||||
order (most recently played first). This is a zero-cost hint from the same
|
||||
``dumpsys media_session`` call: an app shows up here even when it publishes
|
||||
no MediaSession (e.g. TiviMate, R2). Callers may filter system processes
|
||||
such as ``com.google.android.apps.mediashell`` (the Chromecast receiver).
|
||||
"""
|
||||
if not dump:
|
||||
return []
|
||||
|
||||
packages: list[str] = []
|
||||
in_audio = False
|
||||
for line in dump.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not in_audio:
|
||||
in_audio = "Audio playback" in stripped
|
||||
continue
|
||||
marker = "packages="
|
||||
if marker not in stripped:
|
||||
continue
|
||||
pkg_part = stripped.split(marker, 1)[1]
|
||||
for pkg in pkg_part.replace(",", " ").split():
|
||||
if pkg and pkg not in packages:
|
||||
packages.append(pkg)
|
||||
return packages
|
||||
|
||||
def parse_media_session(
|
||||
dump: str, foreground_package: str | None = None
|
||||
) -> MediaSessionInfo | None:
|
||||
|
||||
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
|
||||
recent_tasks: list[dict],
|
||||
media_sessions: list[MediaSessionInfo] | None = None,
|
||||
foreground_package: str | None = None,
|
||||
last_known: dict[str, MediaSessionInfo] | None = None,
|
||||
) -> list[dict] | None:
|
||||
"""Build a list of running app dicts from recent tasks + media sessions.
|
||||
|
||||
@@ -19,6 +20,9 @@ def running_apps_from_tasks(
|
||||
recent_tasks: List of {"package": str} from ADB recent tasks.
|
||||
media_sessions: Optional media sessions for playback state enrichment.
|
||||
foreground_package: The current foreground app package.
|
||||
last_known: Optional per-package TTL-filtered last-known-state cache
|
||||
(P3-3b) — apps without a live session carry their last known
|
||||
playback state as ``last_known_state``/``last_known_title``.
|
||||
|
||||
Returns None if there are fewer than 2 apps (no point showing a switcher
|
||||
with only one option).
|
||||
@@ -57,6 +61,15 @@ def running_apps_from_tasks(
|
||||
app["artist"] = session.artist
|
||||
else:
|
||||
app["state"] = "running"
|
||||
# P3-3b: no live session — surface the TTL-capped last-known state
|
||||
cached = (last_known or {}).get(pkg)
|
||||
if cached is not None:
|
||||
if cached.playing:
|
||||
app["last_known_state"] = "playing"
|
||||
elif cached.paused:
|
||||
app["last_known_state"] = "paused"
|
||||
if cached.title:
|
||||
app["last_known_title"] = cached.title
|
||||
|
||||
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))
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -2,15 +2,24 @@
|
||||
"domain": "nvidia_shield",
|
||||
"name": "NVIDIA Shield",
|
||||
"codeowners": [
|
||||
"@ltomes"
|
||||
"@notexpectedyet"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
|
||||
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
|
||||
"requirements": [
|
||||
"adb-shell>=0.4.0"
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "0.1.1"
|
||||
"version": "2026.10.08",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
"integration_type": "device",
|
||||
"loggers": [
|
||||
"adb_shell",
|
||||
"androidtvremote2"
|
||||
]
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
"""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:
|
||||
- Power/availability: androidtv_remote (local push, fast)
|
||||
- Playback state: Jellyfin (polling, slow 10-30s)
|
||||
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
|
||||
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
|
||||
- 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:
|
||||
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
|
||||
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 time
|
||||
|
||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
@@ -25,34 +32,40 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event
|
||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
ADB_POLL_INTERVAL,
|
||||
ADB_RETRY_INTERVAL,
|
||||
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CHANNEL_DIGIT_DELAY,
|
||||
CONF_ADB_COMMAND_ENABLED,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_APP_ICON_MAP,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_CHANNEL_DIGIT_DELAY,
|
||||
CONF_CHANNEL_SEND_ENTER,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_APP_DIRECTORY,
|
||||
DOMAIN,
|
||||
PROTOCOL_MEDIA_KEYS,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
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 .power import discover_power_siblings
|
||||
|
||||
@@ -73,6 +86,8 @@ _BASE_FEATURES = (
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
)
|
||||
|
||||
_PLAYBACK_FEATURES = (
|
||||
@@ -90,6 +105,49 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
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):
|
||||
@@ -98,11 +156,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
# Overridden as a property below: ADB-health aware
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
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._unsub_listeners: list = []
|
||||
|
||||
@@ -116,11 +175,32 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
# Jellyfin media stream info (resolution, codec, etc.)
|
||||
self._media_info: dict | None = None
|
||||
|
||||
# ADB media session, wake state, and running apps
|
||||
self._adb_poller: AdbMediaSessionPoller | None = None
|
||||
self._adb_poll_unsub: CALLBACK_TYPE | None = None
|
||||
self._adb_awake: bool | None = None
|
||||
self._running_apps: list[dict] = []
|
||||
# ADB-derived state comes from the entry's ShieldConnectionManager
|
||||
# (connection.py) — this entity is a thin callback-view over its store.
|
||||
self._manager: ShieldConnectionManager | None = None
|
||||
self._manager_unsub: CALLBACK_TYPE | None = None
|
||||
# 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
|
||||
def _jellyfin_entity(self) -> str | None:
|
||||
@@ -136,6 +216,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
# G10 (t30): when the protocol transport is live and captured, the
|
||||
# registry card carries the protocol-pushed identity (name from the
|
||||
# device itself, manufacturer 'Google' per the audit ruling); the
|
||||
# pre-capture shape stays the entry-title fallback.
|
||||
captured = (
|
||||
self._manager.protocol_device_data if self._manager is not None else None
|
||||
)
|
||||
if captured:
|
||||
name, manufacturer, model, sw = captured
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name=name or self._entry.title,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
sw_version=sw or None,
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name=self._entry.title,
|
||||
@@ -147,22 +243,34 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
def _power_entity(self) -> str | 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
|
||||
def _adb_store_info(self) -> bool:
|
||||
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
|
||||
|
||||
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
|
||||
|
||||
# Discover power sensor siblings from the power switch
|
||||
if self._power_entity:
|
||||
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
|
||||
|
||||
# Set up ADB media session poller
|
||||
if self._adb_enabled:
|
||||
await self._setup_adb()
|
||||
# Consume the entry's ShieldConnectionManager (thin-view pattern)
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
if manager is not None:
|
||||
self._manager = manager
|
||||
self._manager_unsub = manager.register_listener(
|
||||
self._on_manager_update
|
||||
)
|
||||
|
||||
entities = [self._androidtv_entity]
|
||||
if self._jellyfin_entity:
|
||||
@@ -180,11 +288,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
unsub()
|
||||
self._unsub_listeners.clear()
|
||||
self._clear_intent()
|
||||
if self._adb_poll_unsub:
|
||||
self._adb_poll_unsub()
|
||||
self._adb_poll_unsub = None
|
||||
if self._adb_poller:
|
||||
await self._adb_poller.disconnect()
|
||||
if self._manager_unsub:
|
||||
self._manager_unsub()
|
||||
self._manager_unsub = None
|
||||
# Cancel in-flight entity tasks so they cannot outlive the entity.
|
||||
for task in self._entity_tasks:
|
||||
task.cancel()
|
||||
self._entity_tasks.clear()
|
||||
# P4-2a/t29 G8: drop this entity's service participation (same key
|
||||
# as add). The service itself stays registered (always-on setup).
|
||||
|
||||
|
||||
# ── Intent management ──
|
||||
|
||||
@@ -243,7 +356,27 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
|
||||
intent.command, jf_state, intent.target_state, intent.retry_count,
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
if (
|
||||
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(
|
||||
@@ -268,9 +401,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
new_state_obj = event.data.get("new_state")
|
||||
|
||||
# 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 not self._is_power_on():
|
||||
if self._intent:
|
||||
if (
|
||||
entity_id in (self._androidtv_entity, self._power_entity)
|
||||
and new_state_obj
|
||||
and not self._is_power_on()
|
||||
and self._intent
|
||||
):
|
||||
_LOGGER.debug("Intent cleared: device powered off")
|
||||
self._clear_intent()
|
||||
|
||||
@@ -296,7 +432,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
# Fetch media stream info when playing
|
||||
if new_jf in (STATE_PLAYING, STATE_PAUSED):
|
||||
entity_picture = new_state_obj.attributes.get("entity_picture", "")
|
||||
self.hass.async_create_task(
|
||||
self._track_task(
|
||||
self._fetch_media_info(entity_picture)
|
||||
)
|
||||
else:
|
||||
@@ -313,108 +449,23 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
return None
|
||||
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
|
||||
def _adb_poll_callback(self, _now) -> None:
|
||||
"""Schedule ADB poll as a task."""
|
||||
self.hass.async_create_task(self._adb_poll())
|
||||
|
||||
async def _adb_poll(self) -> None:
|
||||
"""Poll ADB for wake state and media session, then schedule next poll."""
|
||||
try:
|
||||
if not self._adb_poller:
|
||||
return
|
||||
|
||||
# Always check wake state
|
||||
prev_awake = self._adb_awake
|
||||
self._adb_awake = await self._adb_poller.is_awake()
|
||||
|
||||
# Only poll media session when awake and Jellyfin is NOT active
|
||||
jf = self._read(self._jellyfin_entity)
|
||||
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
||||
|
||||
if self._adb_awake:
|
||||
# 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:
|
||||
def _on_manager_update(self) -> None:
|
||||
"""Connection manager's store changed — re-evaluate our state."""
|
||||
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
|
||||
)
|
||||
|
||||
def _track_task(self, coro) -> None:
|
||||
"""Dispatch an entity background task; cancelled on unload.
|
||||
|
||||
Entity-owned tasks (intent retries, media-info fetch) must never go
|
||||
through a bare hass.async_create_task: they would survive removal
|
||||
(reviewer finding F2). The connection manager owns its own tasks and
|
||||
the socket — nothing here touches it directly.
|
||||
"""
|
||||
task = self.hass.async_create_task(coro)
|
||||
self._entity_tasks.add(task)
|
||||
task.add_done_callback(self._entity_tasks.discard)
|
||||
return task
|
||||
|
||||
async def _fetch_media_info(self, entity_picture: str) -> None:
|
||||
"""Read stream info from the HA Jellyfin integration's coordinator."""
|
||||
@@ -431,11 +482,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
|
||||
return True
|
||||
# If we have an active intent for play/pause, media is conceptually active
|
||||
if self._intent and self._intent.target_state in (
|
||||
STATE_PLAYING, STATE_PAUSED
|
||||
):
|
||||
return True
|
||||
return False
|
||||
return bool(
|
||||
self._intent and self._intent.target_state in (STATE_PLAYING, STATE_PAUSED)
|
||||
)
|
||||
|
||||
# ── State ──
|
||||
|
||||
@@ -446,11 +495,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
ADB is the authority — it knows if the device is awake or asleep.
|
||||
The plug being "on" just means the outlet has power (standby).
|
||||
"""
|
||||
# 1. ADB wake state — most accurate
|
||||
if self._adb_awake is not None:
|
||||
return self._adb_awake
|
||||
# 1. Protocol is_on push — fastest truth when the transport is live
|
||||
protocol_is_on = self._manager.is_on if self._manager else None
|
||||
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:
|
||||
power_state = self._read(self._power_entity)
|
||||
if power_state and power_state.state == "off":
|
||||
@@ -469,7 +524,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
power = self._is_power_on()
|
||||
|
||||
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:
|
||||
return MediaPlayerState.IDLE
|
||||
@@ -487,13 +545,20 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
return MediaPlayerState.PAUSED
|
||||
|
||||
# Tier 3: ADB media session (non-Jellyfin apps)
|
||||
if self._adb_poller and self._adb_poller.last_info:
|
||||
adb_info = self._adb_poller.last_info
|
||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||
if adb_info:
|
||||
if adb_info.playing:
|
||||
return MediaPlayerState.PLAYING
|
||||
if adb_info.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
|
||||
return MediaPlayerState.ON
|
||||
|
||||
@@ -502,9 +567,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
if self._jf_active():
|
||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
# ADB media session also supports playback controls
|
||||
if self._adb_poller and self._adb_poller.last_info:
|
||||
adb_info = self._adb_poller.last_info
|
||||
if adb_info.playing or adb_info.paused:
|
||||
adb_info = self._manager.best_session(self.app_id) if self._manager else None
|
||||
if adb_info and (adb_info.playing or adb_info.paused):
|
||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
return _BASE_FEATURES
|
||||
|
||||
@@ -516,13 +580,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def app_id(self):
|
||||
atv = self._read(self._androidtv_entity)
|
||||
return atv.attributes.get("app_id") if atv else None
|
||||
"""Android TV entity first; standalone setups fall back to the
|
||||
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
|
||||
def app_name(self):
|
||||
atv = self._read(self._androidtv_entity)
|
||||
return atv.attributes.get("app_name") if atv else None
|
||||
"""P5-1 resolution order — explicit override first:
|
||||
|
||||
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
|
||||
def media_content_type(self):
|
||||
@@ -558,7 +657,40 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
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:
|
||||
if not self._jf_active():
|
||||
@@ -584,11 +716,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
def _get_adb_now_playing(self) -> dict | None:
|
||||
"""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
|
||||
if not self._adb_store_info:
|
||||
# Privacy mode: show that something is playing but not what
|
||||
info = self._adb_poller.last_info
|
||||
if info.playing or info.paused:
|
||||
return {
|
||||
"state": "playing" if info.playing else "paused",
|
||||
@@ -597,7 +729,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
}
|
||||
return None
|
||||
|
||||
info = self._adb_poller.last_info
|
||||
if not info.title:
|
||||
return None
|
||||
np = {
|
||||
@@ -611,6 +742,42 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
np["album"] = info.album
|
||||
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:
|
||||
"""Read current values from discovered power sensors."""
|
||||
if not self._power_sensors:
|
||||
@@ -629,22 +796,40 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
def _get_running_apps(self) -> list[dict] | None:
|
||||
"""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
|
||||
if not self._adb_store_info:
|
||||
return None
|
||||
return running_apps_from_tasks(
|
||||
self._running_apps,
|
||||
self._adb_poller.all_sessions,
|
||||
apps,
|
||||
self._manager.active_sessions,
|
||||
foreground_package=self.app_id,
|
||||
last_known=self._manager.last_known_map(),
|
||||
)
|
||||
|
||||
# G12 (t30): attributes that change with every poll must not bloat the
|
||||
# database (entity doc) — dag internals are declared unrecorded.
|
||||
_unrecorded_attributes = frozenset(
|
||||
{
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING,
|
||||
ATTR_MEDIA_DAG_RUNNING_APPS,
|
||||
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
||||
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
||||
ATTR_MEDIA_DAG_ROLE,
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
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()
|
||||
if not 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,
|
||||
ATTR_MEDIA_DAG_ROLE: "source",
|
||||
@@ -686,16 +871,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
await self._cmd("turn_off")
|
||||
|
||||
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):
|
||||
await self._cmd("volume_down")
|
||||
await self._remote_key_cmd("volume_down", "volume_down")
|
||||
|
||||
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:
|
||||
"""Send a media command. Uses ADB dispatch for non-Jellyfin apps when available."""
|
||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||
"""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:
|
||||
self._set_intent(ha_service, intent_state)
|
||||
self.async_write_ha_state()
|
||||
@@ -704,12 +910,30 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
jf = self._read(self._jellyfin_entity)
|
||||
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
|
||||
|
||||
if self._adb_poller and self._adb_poller.connected and not jf_active:
|
||||
success = await self._adb_poller.send_media_command(adb_command)
|
||||
if success:
|
||||
# 1. Protocol key push (fast; works whenever the transport is up)
|
||||
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
|
||||
|
||||
# Fall back to Android TV Remote
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
|
||||
await self._cmd(ha_service)
|
||||
|
||||
async def async_media_play(self):
|
||||
@@ -729,7 +953,183 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
async def async_bring_to_foreground(self, package: str) -> bool:
|
||||
"""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")
|
||||
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
|
||||
# G18 (2026-05-20 blog): BrowseMediaSource must carry the domain
|
||||
# parameter so the frontend can attribute the media tree.
|
||||
children = [
|
||||
BrowseMediaSource(
|
||||
title=name,
|
||||
media_class=MediaClass.APP,
|
||||
media_content_type="app",
|
||||
media_content_id=pkg,
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
domain=DOMAIN,
|
||||
)
|
||||
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,
|
||||
domain=DOMAIN,
|
||||
)
|
||||
|
||||
# ── 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.Required("entity_id"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _async_adb_command_service(call) -> None:
|
||||
"""Run a raw ADB shell command on the target connection (t29 G8).
|
||||
|
||||
The integration's async_setup registers this ALWAYS; the entry's
|
||||
adb_command_enabled option gates USE (participants register from
|
||||
async_added_to_hass). Failures raise ServiceValidationError with
|
||||
translated keys (action-exceptions) — never warning logs.
|
||||
"""
|
||||
command = str(call.data.get("command") or "").strip()
|
||||
if not command:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_command"
|
||||
)
|
||||
entity_id = call.data.get("entity_id")
|
||||
manager = _ADB_COMMAND_TARGETS.get(entity_id)
|
||||
if manager is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_target",
|
||||
)
|
||||
poller = manager.adb_poller # public accessor (t21 F6)
|
||||
if poller is None or not manager.connected:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="adb_unavailable"
|
||||
)
|
||||
result = await poller.shell(command)
|
||||
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
|
||||
@@ -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.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
|
||||
from homeassistant.core import HomeAssistant, callback, Event
|
||||
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
@@ -38,6 +39,8 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
# G12 (t30): poll-driven attributes never belong in the database
|
||||
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
@@ -60,49 +63,82 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
|
||||
|
||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
"""Current running app on the Shield."""
|
||||
"""Current running app on the Shield.
|
||||
|
||||
_attr_name = "Current App"
|
||||
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_translation_key = "current_app"
|
||||
_attr_icon = "mdi:application"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
|
||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
|
||||
|
||||
def _manager(self) -> ShieldConnectionManager | None:
|
||||
manager = getattr(self._entry, "runtime_data", None)
|
||||
return manager if isinstance(manager, ShieldConnectionManager) else None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self._unsub_listeners.append(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._androidtv_entity], self._handle_state_change
|
||||
)
|
||||
)
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
# Refresh when the connection manager updates its store.
|
||||
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
|
||||
|
||||
@callback
|
||||
def _handle_state_change(self, event: Event) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_store_update(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if not state:
|
||||
return None
|
||||
if state:
|
||||
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
|
||||
def extra_state_attributes(self) -> dict:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if not state:
|
||||
return {}
|
||||
if state:
|
||||
return {
|
||||
"app_id": state.attributes.get("app_id"),
|
||||
"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):
|
||||
"""What's currently playing via Jellyfin."""
|
||||
|
||||
_attr_name = "Now Playing"
|
||||
_attr_translation_key = "now_playing"
|
||||
_attr_icon = "mdi:play-circle"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
adb_command:
|
||||
# Both fields required — integrating targeting must not be optional
|
||||
# (docs/dev_101_services: no default target when none specified).
|
||||
name: ADB command
|
||||
description: Send a raw ADB shell command to the persistent connection of
|
||||
an Nvidia Shield entry (the entry must have the adb_command_enabled
|
||||
option on; the integration gates USE, not registration).
|
||||
fields:
|
||||
command:
|
||||
name: Command
|
||||
description: The shell command to run on the device, e.g.
|
||||
"dumpsys power | head -5".
|
||||
required: true
|
||||
example: "dumpsys power | head -5"
|
||||
selector:
|
||||
text:
|
||||
entity_id:
|
||||
name: Entity
|
||||
description: The Nvidia Shield media player entity to target.
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
integration: nvidia_shield
|
||||
domain: media_player
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||
"data": {
|
||||
"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",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
@@ -14,7 +14,9 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"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": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||
@@ -24,21 +26,48 @@
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_port": "ADB port number. Default is 5555."
|
||||
"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.",
|
||||
"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": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"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.",
|
||||
"yaml_seed_only": {},
|
||||
"yaml_seed": {
|
||||
"title": "YAML succeeded earlier",
|
||||
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -54,19 +83,69 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"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": {
|
||||
"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_input": "Which input on the receiver.",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"adb_command": {
|
||||
"name": "ADB command",
|
||||
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
"description": "The shell command to run on the device."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Nvidia Shield media player to target."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty \u2014 nothing was sent."
|
||||
},
|
||||
"unsupported_target": {
|
||||
"title": "Unsupported entity for adb_command",
|
||||
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||
},
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_app": {
|
||||
"name": "Current app"
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
|
||||
"data": {
|
||||
"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",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver Input",
|
||||
@@ -14,7 +14,9 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"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": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
|
||||
@@ -24,21 +26,48 @@
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
|
||||
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
|
||||
"adb_port": "ADB port number. Default is 5555."
|
||||
"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.",
|
||||
"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": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"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": {
|
||||
"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.",
|
||||
"yaml_seed_only": {},
|
||||
"yaml_seed": {
|
||||
"title": "YAML succeeded earlier",
|
||||
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -54,19 +83,69 @@
|
||||
"adb_enabled": "Enable ADB Media Session",
|
||||
"adb_store_app_info": "Show App Media Info",
|
||||
"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": {
|
||||
"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_input": "Which input on the receiver.",
|
||||
"power_entity": "Smart plug switch for reliable on/off detection.",
|
||||
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
|
||||
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
|
||||
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
|
||||
"adb_port": "ADB port (default 5555)."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"adb_command": {
|
||||
"name": "ADB command",
|
||||
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
"description": "The shell command to run on the device."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "The Nvidia Shield media player to target."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_command": {
|
||||
"title": "Empty command",
|
||||
"message": "The command field is empty \u2014 nothing was sent."
|
||||
},
|
||||
"unsupported_target": {
|
||||
"title": "Unsupported entity for adb_command",
|
||||
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
|
||||
},
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"current_app": {
|
||||
"name": "Current app"
|
||||
},
|
||||
"now_playing": {
|
||||
"name": "Now playing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
name = "ha-nvidia-shield"
|
||||
version = "0.1.1"
|
||||
version = "2026.10.08"
|
||||
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -14,14 +14,11 @@ dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.4",
|
||||
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
|
||||
# for HACS installs; used editable from a sibling checkout for development.
|
||||
# Planned migration to a proper PyPI package.
|
||||
"ha-adb-common",
|
||||
"androidtvremote2>=0.2.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
ha-adb-common = { path = "../ha-adb-common", editable = true }
|
||||
# The shared ADB helpers are vendored at
|
||||
# custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
|
||||
# this fork, so no extra dev dependency (and no unavailable sibling checkout).
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
@@ -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,385 @@
|
||||
"""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.exceptions",
|
||||
"homeassistant.core",
|
||||
"homeassistant.const",
|
||||
"homeassistant.components",
|
||||
"homeassistant.components.media_player",
|
||||
"homeassistant.components.remote",
|
||||
"homeassistant.components.button",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"homeassistant.helpers.event",
|
||||
"homeassistant.helpers.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,
|
||||
domain=None, # t30 G18 (2026-05-20 blog contract)
|
||||
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.domain = domain
|
||||
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", (), {})
|
||||
async def _stub_set_unique_id(self, unique_id):
|
||||
# hub shape: record; a full flow test file scopes its own version
|
||||
self._unique_id = unique_id
|
||||
|
||||
def _noop_abort(self, *, error=None, **kwargs):
|
||||
return None
|
||||
|
||||
class _ConfigFlowStub:
|
||||
"""ConfigFlow shape used by entity tests (t29 G3a API included).
|
||||
|
||||
Late loads under the hub stubs (e.g. test_init designs run AFTER the
|
||||
other flow files) still get the base plumbing the flow modules rely
|
||||
on — show/create/abort/unique-id — so the flow classes never import
|
||||
against a bare base.
|
||||
"""
|
||||
|
||||
async_set_unique_id = _stub_set_unique_id
|
||||
_abort_if_unique_id_configured = _noop_abort
|
||||
|
||||
def _async_handle_discovery_without_unique_id(self):
|
||||
# t32 F2 (audited helper): recording no-op at hub scope
|
||||
pass
|
||||
|
||||
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}
|
||||
|
||||
def async_abort(self, *, reason):
|
||||
return {"type": "abort", "reason": reason}
|
||||
|
||||
def _get_reauth_entry(self, entry=None):
|
||||
if entry is not None:
|
||||
return entry
|
||||
return None # the caller's cached entry is used
|
||||
|
||||
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
|
||||
# which updates, schedules the reload and aborts; the stub records both.
|
||||
|
||||
def async_update_reload_and_abort(self, entry, **kwargs):
|
||||
"""Real-HA semantics: update, schedule the reload, abort."""
|
||||
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
|
||||
for key, value in updates.items():
|
||||
if key in ("options", "data", "unique_id"):
|
||||
setattr(entry, key, value)
|
||||
else:
|
||||
getattr(entry, "options", {}).setdefault(key, value) # best effort
|
||||
scheduler = getattr(self.hass, "config_entries", None)
|
||||
if scheduler is not None and hasattr(
|
||||
scheduler, "async_schedule_reload"
|
||||
):
|
||||
scheduler.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
def _config_flow_init(self, *args, **kwargs):
|
||||
self.context = getattr(self, "context", {}) or {}
|
||||
self.hass = getattr(self, "hass", None)
|
||||
|
||||
_config_flow_base = type(
|
||||
"ConfigFlow",
|
||||
(_ConfigFlowStub,),
|
||||
{
|
||||
"__init_subclass__": classmethod(
|
||||
lambda cls, domain=None, **kwargs: None
|
||||
),
|
||||
"__init__": _config_flow_init,
|
||||
},
|
||||
)
|
||||
config_entries.ConfigFlow = _config_flow_base
|
||||
class _OptionsFlowStub:
|
||||
def __init__(self):
|
||||
self.config_entry = None
|
||||
|
||||
def add_suggested_values_to_schema(self, schema, suggested):
|
||||
# real HA merges hints; the stub records them on the schema dict
|
||||
schema_dict = getattr(schema, "schema", None) or (
|
||||
schema if isinstance(schema, dict) else {}
|
||||
)
|
||||
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||
return schema
|
||||
|
||||
class _OptionsFlowWithReload(_OptionsFlowStub):
|
||||
"""Options flow that reloads after a successful save (real HA)."""
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||
if entry_id is not None and scheduler is not None and hasattr(
|
||||
scheduler, "async_schedule_reload"
|
||||
):
|
||||
scheduler.async_schedule_reload(entry_id)
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
config_entries.OptionsFlow = _OptionsFlowStub
|
||||
config_entries.SOURCE_IMPORT = "import"
|
||||
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
|
||||
sys.modules["homeassistant.config_entries"] = config_entries
|
||||
|
||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||
|
||||
class ServiceValidationErrorStubError(Exception):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||
self.translation_domain = kwargs.get("translation_domain")
|
||||
self.translation_key = kwargs.get("translation_key")
|
||||
|
||||
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||
sys.modules["homeassistant.exceptions"] = exceptions
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
core.CALLBACK_TYPE = object
|
||||
core.Event = object
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
ha_const = types.ModuleType("homeassistant.const")
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.STATE_PLAYING = "playing"
|
||||
ha_const.STATE_PAUSED = "paused"
|
||||
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
|
||||
sensor_platform = types.ModuleType("homeassistant.components.sensor")
|
||||
sensor_platform.SensorEntity = type("SensorEntity", (), {})
|
||||
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
|
||||
sys.modules["homeassistant.components.sensor"] = sensor_platform
|
||||
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||
|
||||
class _DeviceRegistry:
|
||||
def __init__(self):
|
||||
self.devices: dict[str, object] = {}
|
||||
|
||||
def async_get(self, device_id):
|
||||
return self.devices.get(device_id)
|
||||
|
||||
_device_registry_shared = _DeviceRegistry()
|
||||
|
||||
device_registry.async_get = lambda hass: _device_registry_shared
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
|
||||
helpers.entity_registry = entity_registry
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
|
||||
event = types.ModuleType("homeassistant.helpers.event")
|
||||
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
|
||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||
helpers.event = event
|
||||
helpers.selector = types.SimpleNamespace(
|
||||
EntitySelector=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
|
||||
entity.EntityCategory = types.SimpleNamespace(
|
||||
CONFIG="config", DIAGNOSTIC="diagnostic"
|
||||
)
|
||||
helpers.entity = entity
|
||||
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
|
||||
entity_platform.AddEntitiesCallback = object
|
||||
helpers.entity_platform = entity_platform
|
||||
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",
|
||||
"sensor",
|
||||
"media_player",
|
||||
"remote",
|
||||
"diagnostics",
|
||||
"init",
|
||||
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||
if name == "init" and "nsh.init" in sys.modules:
|
||||
continue
|
||||
if f"nsh.{name}" not in sys.modules:
|
||||
file_name = "__init__.py" if name == "init" else f"{name}.py"
|
||||
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
|
||||
return {
|
||||
name: sys.modules[f"nsh.{name}"]
|
||||
for name in (
|
||||
"power",
|
||||
"jellyfin",
|
||||
"sensor",
|
||||
"media_player",
|
||||
"remote",
|
||||
"diagnostics",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class 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() == []
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
"""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.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
# Loaded execution runs beneath the stubs; never escape this file either.
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
)
|
||||
|
||||
|
||||
def _install_import_stubs() -> None:
|
||||
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
|
||||
if sys.modules.get("voluptuous") is not None:
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
vol.Required = lambda key, default=None: key
|
||||
vol.Optional = lambda key, default=None: key
|
||||
vol.Schema = lambda schema: schema
|
||||
sys.modules["voluptuous"] = vol
|
||||
|
||||
ha = types.ModuleType("homeassistant")
|
||||
sys.modules["homeassistant"] = ha
|
||||
|
||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||
|
||||
class ConfigFlow:
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
|
||||
def _form(step_id, errors):
|
||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
class _BaseOptionsFlow:
|
||||
pass
|
||||
|
||||
# t29 G3b: the current options-flow API surface on the local stub
|
||||
class _SuggestedMixin:
|
||||
def add_suggested_values_to_schema(self, schema, suggested):
|
||||
schema_dict = getattr(schema, "schema", None) or (
|
||||
schema if isinstance(schema, dict) else {}
|
||||
)
|
||||
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||
return schema
|
||||
|
||||
class OptionsFlowWithReload(_SuggestedMixin):
|
||||
def async_create_entry(self, *, title, data):
|
||||
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||
if entry_id is not None and scheduler is not None and hasattr(
|
||||
scheduler, "async_schedule_reload"
|
||||
):
|
||||
scheduler.async_schedule_reload(entry_id)
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
config_entries.OptionsFlow = _BaseOptionsFlow
|
||||
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||
sys.modules["homeassistant.config_entries"] = config_entries
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
helpers.entity_registry = entity_registry
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||
sys.modules["homeassistant.helpers"] = helpers
|
||||
|
||||
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
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,424 @@
|
||||
"""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.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
"nsh.entry",
|
||||
)
|
||||
|
||||
FAKE_PAIRING_CODE = "8421"
|
||||
FAKE_DEVICE_NAME = "mdarcy"
|
||||
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
|
||||
class FakeInvalidAuthError(Exception):
|
||||
"""androidtvremote2.exceptions.InvalidAuth stand-in."""
|
||||
|
||||
|
||||
class FakeProtocolClient:
|
||||
"""Proto client used by the flow pairing path (deterministic)."""
|
||||
|
||||
def __init__(self, fail_code=False):
|
||||
self.cert_generated = False
|
||||
self.pairing_started = False
|
||||
self.finished_codes: list[str] = []
|
||||
self.disconnected = 0
|
||||
self._fail_code = fail_code
|
||||
|
||||
async def async_generate_cert_if_missing(self):
|
||||
self.cert_generated = True
|
||||
return True
|
||||
|
||||
async def async_start_pairing(self):
|
||||
self.pairing_started = True
|
||||
return FAKE_PAIRING_CODE # deterministic "device-side PIN"
|
||||
|
||||
async def async_finish_pairing(self, pairing_code: str) -> None:
|
||||
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
|
||||
raise FakeInvalidAuthError("device rejected the pairing code")
|
||||
self.finished_codes.append(pairing_code)
|
||||
|
||||
async def async_get_name_and_mac(self):
|
||||
return FAKE_DEVICE_NAME, FAKE_MAC
|
||||
|
||||
def disconnect(self):
|
||||
self.disconnected += 1
|
||||
|
||||
|
||||
def _install_stubs() -> None:
|
||||
"""Import-time stand-ins (idempotent, scoped by the autouse fixture)."""
|
||||
if sys.modules.get("voluptuous") is not None:
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
vol.Required = lambda key, default=None: key
|
||||
vol.Optional = lambda key, default=None: key
|
||||
vol.Schema = lambda schema: schema
|
||||
sys.modules["voluptuous"] = vol
|
||||
|
||||
ha = types.ModuleType("homeassistant")
|
||||
sys.modules["homeassistant"] = ha
|
||||
|
||||
config_entries = types.ModuleType("homeassistant.config_entries")
|
||||
|
||||
class ConfigFlow:
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
kwargs.pop("domain", None)
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def async_show_form(self, *, step_id, data_schema=None, errors=None):
|
||||
return _form(step_id, errors or {})
|
||||
|
||||
def async_create_entry(self, *, title, data, options=None):
|
||||
return {"type": "create_entry", "title": title, "data": data,
|
||||
"options": options}
|
||||
|
||||
def async_abort(self, *, reason):
|
||||
return {"type": "abort", "reason": reason}
|
||||
|
||||
async def async_set_unique_id(self, unique_id):
|
||||
self._unique_id = unique_id
|
||||
|
||||
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
|
||||
if getattr(self, "_unique_id", None) in active_unique_ids:
|
||||
raise AbortIfConfiguredError()
|
||||
|
||||
def _form(step_id, errors):
|
||||
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
|
||||
|
||||
class AbortIfConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
active_unique_ids = set()
|
||||
|
||||
class OptionsFlow:
|
||||
pass
|
||||
# t29 G3b: the current options-flow API surface on the local stub
|
||||
class _SuggestedMixin:
|
||||
def add_suggested_values_to_schema(self, schema, suggested):
|
||||
schema_dict = getattr(schema, "schema", None) or (
|
||||
schema if isinstance(schema, dict) else {}
|
||||
)
|
||||
schema_dict.setdefault("__suggested__", dict(suggested))
|
||||
return schema
|
||||
|
||||
class OptionsFlowWithReload(_SuggestedMixin):
|
||||
def async_create_entry(self, *, title, data):
|
||||
entry_id = getattr(self.config_entry, "entry_id", None)
|
||||
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
|
||||
if entry_id is not None and scheduler is not None and hasattr(
|
||||
scheduler, "async_schedule_reload"
|
||||
):
|
||||
scheduler.async_schedule_reload(entry_id)
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
|
||||
|
||||
# t29 G3a: the current config-entries API surface on the local stub
|
||||
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
|
||||
|
||||
def _update_reload_and_abort(self, entry, **kwargs):
|
||||
updates = kwargs.get("options_updates") or {}
|
||||
options = entry.options
|
||||
for key, value in updates.items():
|
||||
options[key] = value
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
|
||||
|
||||
config_entries.ConfigFlow = ConfigFlow
|
||||
config_entries.OptionsFlow = OptionsFlow
|
||||
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
|
||||
config_entries.ConfigEntry = type("ConfigEntry", (), {})
|
||||
sys.modules["homeassistant.config_entries"] = config_entries
|
||||
|
||||
core = types.ModuleType("homeassistant.core")
|
||||
core.HomeAssistant = object
|
||||
core.callback = lambda fn: fn
|
||||
sys.modules["homeassistant.core"] = core
|
||||
|
||||
ha_const = types.ModuleType("homeassistant.const")
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
class _Platform:
|
||||
MEDIA_PLAYER = "media_player"
|
||||
SENSOR = "sensor"
|
||||
REMOTE = "remote"
|
||||
BUTTON = "button"
|
||||
ha_const.Platform = _Platform
|
||||
sys.modules["homeassistant.const"] = ha_const
|
||||
|
||||
helpers = types.ModuleType("homeassistant.helpers")
|
||||
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
|
||||
|
||||
class _Registry:
|
||||
entities: dict = {}
|
||||
|
||||
def async_get(self, entity_id):
|
||||
return None
|
||||
|
||||
entity_registry.async_get = lambda hass: _Registry()
|
||||
helpers.entity_registry = entity_registry
|
||||
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
|
||||
sys.modules["homeassistant.helpers"] = helpers
|
||||
|
||||
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
|
||||
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
|
||||
helpers.device_registry = device_registry
|
||||
sys.modules["homeassistant.helpers.device_registry"] = device_registry
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
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,271 @@
|
||||
"""tests/test_init.py — the t30 G6 structure test file.
|
||||
|
||||
Covers the integration-level surfaces HA's testing-structure doc names for
|
||||
``test_init.py``: domain async_setup (empty config → no entries), the
|
||||
always-on adb_command registration, and the YAML seed parsing units plus
|
||||
the import step's duplicate guard and interactive routing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
|
||||
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
|
||||
# scoped_stubs pattern): the config-flow files install their own stub faces
|
||||
# and pop them per-test; test_init's runs must never leave a hub-stub set
|
||||
# behind that a later file's loader would bind against.
|
||||
_INIT_STUB_NAMES = (
|
||||
"voluptuous",
|
||||
"homeassistant",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.core",
|
||||
"homeassistant.helpers",
|
||||
"homeassistant.helpers.entity_registry",
|
||||
"homeassistant.helpers.device_registry",
|
||||
"homeassistant.helpers.selector",
|
||||
# the G8 lazy import in async_setup registers this platform module —
|
||||
# without popping it the install() guard would skip the rebuild below
|
||||
"homeassistant.components.media_player",
|
||||
"nsh.helpers",
|
||||
"nsh.config_flow",
|
||||
"nsh.init",
|
||||
"nsh.media_player",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _integration_module():
|
||||
"""HA stubs + the integration __init__ module under nsh.init."""
|
||||
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES}
|
||||
entity_stublib.install()
|
||||
if "nsh.init" not in sys.modules:
|
||||
import conftest
|
||||
|
||||
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||
yield sys.modules["nsh.init"]
|
||||
for name in _INIT_STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
|
||||
|
||||
class _SeedEntriesStub:
|
||||
"""config-entries shape for flow seeding tests: entries + flow records."""
|
||||
|
||||
def __init__(self, entries=()):
|
||||
self._entries = list(entries)
|
||||
self.flows = [] # (domain, context, data) records of flow.async_init
|
||||
|
||||
def async_entries(self):
|
||||
return list(self._entries)
|
||||
|
||||
class _Flows:
|
||||
def __init__(self, holder):
|
||||
self._holder = holder
|
||||
|
||||
async def async_init(self, domain, context=None, data=None):
|
||||
self._holder.flows.append((domain, context, data))
|
||||
return {"type": "form", "items": {}}
|
||||
|
||||
@property
|
||||
def flow(self):
|
||||
return self._Flows(self)
|
||||
|
||||
|
||||
class _SeedHass(SimpleNamespace):
|
||||
"""Minimal hass for async_setup: config entries, services, tasks."""
|
||||
|
||||
def __init__(self, entries=()):
|
||||
config_entries = _SeedEntriesStub()
|
||||
config_entries.updating_entry_calls = []
|
||||
self._services = {("nvidia_shield", "adb_command"): None}
|
||||
super().__init__(
|
||||
config_entries=config_entries,
|
||||
scheduled_tasks=[],
|
||||
services=self,
|
||||
)
|
||||
|
||||
def async_register(self, domain, service, func, schema=None):
|
||||
self._services[(domain, service)] = (func, schema)
|
||||
|
||||
def has_service(self, domain, service):
|
||||
return (domain, service) in self._services
|
||||
|
||||
def async_create_task(self, coro):
|
||||
task = asyncio.ensure_future(coro)
|
||||
self.scheduled_tasks.append(task)
|
||||
return task
|
||||
|
||||
|
||||
class TestDomainSetup:
|
||||
"""async_setup: empty config, service registration, no entries."""
|
||||
|
||||
async def test_empty_config_creates_nothing(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
|
||||
result = await _integration_module.async_setup(hass, {})
|
||||
|
||||
assert result is True
|
||||
assert hass.config_entries.flows == [] # nothing seeded
|
||||
assert hass.services.has_service("nvidia_shield", "adb_command")
|
||||
|
||||
|
||||
class TestYamlSeedBlocks:
|
||||
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
|
||||
|
||||
def test_block_normalized(self, _integration_module):
|
||||
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT
|
||||
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||
)
|
||||
|
||||
assert len(blocks) == 1
|
||||
seed = blocks[0]
|
||||
assert seed["name"] == "Living"
|
||||
assert seed[CONF_ADB_HOST] == "10.0.0.31"
|
||||
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||
assert seed["adb_enabled"] is True
|
||||
assert "unique_id" not in seed # optional key only
|
||||
|
||||
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||
)
|
||||
|
||||
assert len(blocks) == 1
|
||||
# protocol fields are prefill hints ONLY (pairing stays interactive)
|
||||
assert blocks[0]["protocol_host"] == "shield.local"
|
||||
assert blocks[0]["adb_port"] == 5000
|
||||
|
||||
def test_list_and_single_block_shapes(self, _integration_module):
|
||||
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
|
||||
|
||||
assert len(single) == 1 and len(listed) == 2
|
||||
|
||||
|
||||
class TestSeeding:
|
||||
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
|
||||
|
||||
async def test_shaped_block_seeds_a_flow(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
|
||||
await _integration_module.async_setup(
|
||||
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
|
||||
)
|
||||
if hass.scheduled_tasks:
|
||||
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||
|
||||
assert len(hass.config_entries.flows) == 1
|
||||
domain, context, data = hass.config_entries.flows[0]
|
||||
assert domain == "nvidia_shield"
|
||||
assert context["source"] == "import"
|
||||
assert data["adb_host"] == "10.0.0.5"
|
||||
|
||||
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
|
||||
result = await _integration_module.async_setup(
|
||||
hass, {"nvidia_shield": [{"name": "X"}]}
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert hass.config_entries.flows == [] # hostless → warning only
|
||||
|
||||
|
||||
class TestImportStep:
|
||||
"""async_step_import: duplicate guards + interactive routing."""
|
||||
|
||||
def _flow(self, entries):
|
||||
# the flow module loads under these stubs and the base plumbing is
|
||||
# injected locally — importing test_standalone_setup here would
|
||||
# leak its local stub set into later test files.
|
||||
from nsh.config_flow import NvidiaShieldConfigFlow
|
||||
|
||||
hass = SimpleNamespace(
|
||||
config_entries=_SeedEntriesStub(entries),
|
||||
)
|
||||
hass.config_entries.flows = []
|
||||
flow = NvidiaShieldConfigFlow()
|
||||
flow.hass = hass
|
||||
flow.context = {}
|
||||
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
|
||||
flow.async_set_unique_id = None # set below
|
||||
flow._collected_unique_id = None
|
||||
|
||||
async def set_unique_id(uid):
|
||||
flow._collected_unique_id = uid
|
||||
|
||||
flow.async_set_unique_id = set_unique_id
|
||||
|
||||
def abort_if_unique_id_configured(*args, **kwargs):
|
||||
for entry in hass.config_entries.async_entries():
|
||||
if entry.unique_id == flow._collected_unique_id:
|
||||
from nsh.config_flow import AbortIfConfiguredError
|
||||
|
||||
raise AbortIfConfiguredError(f"{entry.unique_id}")
|
||||
|
||||
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
|
||||
# the base ConfigFlow shows forms through the stub plumbing (matching
|
||||
# how the standalone flow tests install it):
|
||||
flow.async_show_form = lambda **kwargs: {
|
||||
"type": "form",
|
||||
**kwargs,
|
||||
}
|
||||
flow.async_create_entry = lambda **kwargs: {
|
||||
"type": "create_entry",
|
||||
**kwargs,
|
||||
}
|
||||
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
|
||||
flow._async_current_entries = lambda: list(
|
||||
hass.config_entries.async_entries()
|
||||
)
|
||||
return flow
|
||||
|
||||
async def test_ui_configured_host_never_re_seeded(self):
|
||||
from nsh.const import CONF_ADB_HOST
|
||||
|
||||
existing = SimpleNamespace(
|
||||
entry_id="ui1",
|
||||
unique_id=None,
|
||||
options={CONF_ADB_HOST: "10.0.0.31"},
|
||||
)
|
||||
# options live on the entry (conf zeroconf guard reads them)
|
||||
flow = self._flow([existing])
|
||||
|
||||
result = await flow.async_step_import(
|
||||
{"name": "A", "adb_host": "10.0.0.31"}
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
|
||||
async def test_virtual_unique_id_blocks_reseed(self):
|
||||
flow_holder = SimpleNamespace(
|
||||
entry_id="seed1",
|
||||
unique_id="yaml:Living:10.0.0.31",
|
||||
options={},
|
||||
)
|
||||
flow = self._flow([flow_holder])
|
||||
|
||||
result = await flow.async_step_import(
|
||||
{"name": "Living", "adb_host": "10.0.0.31"}
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
|
||||
async def test_fresh_block_routes_into_the_user_flow(self):
|
||||
flow = self._flow([])
|
||||
|
||||
result = await flow.async_step_import(
|
||||
{"name": "Fresh", "adb_host": "10.0.0.99"}
|
||||
)
|
||||
|
||||
# interactive pairing — the pre-filled user step, one visit
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
@@ -0,0 +1,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"
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user