Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49bc23ae34 | ||
|
|
0357f47284 | ||
|
|
dc3cfbbe3e | ||
|
|
97615408c0 | ||
|
|
1c6c223f27 | ||
|
|
c3e4c7855c | ||
|
|
b2aab15ebf | ||
|
|
b3f897c36e | ||
|
|
0cfa045190 | ||
|
|
b03cfbae13 | ||
|
|
89c05f1dcd | ||
|
|
2e1d162ef4 | ||
|
|
fbc72dfd9e | ||
|
|
3175582419 | ||
|
|
7608518d97 | ||
|
|
aea9f9f9d2 | ||
|
|
130dee4bc6 | ||
|
|
151c49a0da | ||
|
|
fb481c3c14 | ||
|
|
81f70585cd | ||
|
|
75ebeb40b0 | ||
|
|
86aa2827a2 | ||
|
|
60d884b76a | ||
|
|
dd23d01a88 | ||
|
|
be3abae3e7 | ||
|
|
acc7bc3d01 |
No files matched your search
+114
-38
@@ -1,10 +1,19 @@
|
||||
# Publish a date-based release every time master moves.
|
||||
# Publish a date-based release every time master moves — and keep HACS able to
|
||||
# see every one of them (t42).
|
||||
#
|
||||
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
|
||||
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
|
||||
# manifest to force a specific release, e.g. after a meaningful change); otherwise
|
||||
# the version is today's date (UTC), so a plain push to master publishes the
|
||||
# day's release. Repeated pushes on the same day are deduped by tag existence.
|
||||
# Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N)
|
||||
# for further releases in the same UTC day. The decision lives in
|
||||
# scripts/determine-release-version.sh so it can be tested locally:
|
||||
# the manifest `version` is untagged -> that version (bump it to force one)
|
||||
# else today's UTC date is untagged -> vYYYY.MM.DD
|
||||
# else -> vYYYY.MM.DD.N, lowest untagged N >= 2
|
||||
#
|
||||
# Why the write-back below exists: the resolved version is written into
|
||||
# custom_components/nvidia_shield/manifest.json and committed BEFORE the tag, so
|
||||
# the released artifact and its tag always agree. Without it HACS compares the
|
||||
# installed version against a manifest that never moves — v2026.10.09 was
|
||||
# published while master's manifest still said 2026.10.08, so the second release
|
||||
# of that day was offered to nobody.
|
||||
#
|
||||
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
|
||||
# nvidia_shield/) so the integration is installable manually from any release.
|
||||
@@ -28,15 +37,28 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# The job token pushes the tag and creates the release.
|
||||
# The job token pushes the manifest commit and the tag, and creates the release.
|
||||
contents: write
|
||||
|
||||
env:
|
||||
ZIP_NAME: nvidia_shield.zip
|
||||
MANIFEST: custom_components/nvidia_shield/manifest.json
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# t42/t44 R3: this job pushes the write-back commit itself, and a run starting
|
||||
# from that commit would release the same version twice. The guard is anchored to
|
||||
# the release commit's SUBJECT (`chore(release): …`), not to the `[skip ci]`
|
||||
# marker anywhere in the message: a developer commit whose body happens to
|
||||
# mention [skip ci] must still release — with contains() it silently did not.
|
||||
# The marker stays in the commit message because it is the convention Gitea
|
||||
# itself may honour to skip the run outright (README documents that platform
|
||||
# layer, and that the job guard is a no-op there rather than the trigger).
|
||||
# workflow_dispatch has no head_commit, hence the first clause.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
!startsWith(github.event.head_commit.message || '', 'chore(release):')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -45,61 +67,115 @@ jobs:
|
||||
ref: master
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Determine release version
|
||||
- name: Determine the release version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
|
||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||
# An unreleased manifest version wins over today's date.
|
||||
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
|
||||
VERSION="${DATE_VERSION}"
|
||||
else
|
||||
VERSION="${MANIFEST_VERSION}"
|
||||
fi
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "v${VERSION} is already tagged — nothing to release"
|
||||
echo "exists=yes" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "releasing v${VERSION}"
|
||||
echo "exists=no" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT
|
||||
# is set inside a step, to this job's outputs too. RELEASE=no means the
|
||||
# version already exists: tag and publish nothing.
|
||||
scripts/determine-release-version.sh
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
- name: Write the version back into the manifest
|
||||
id: bump
|
||||
if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
|
||||
|
||||
python3 - "$MANIFEST" "$VERSION" <<'PY'
|
||||
import json, pathlib, sys
|
||||
path, version = pathlib.Path(sys.argv[1]), sys.argv[2]
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
data["version"] = version
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
git add "$MANIFEST"
|
||||
git commit -m "chore(release): v${VERSION} [skip ci]"
|
||||
|
||||
# Never tag a commit whose manifest the default branch does not carry:
|
||||
# a branch protection rule (or a push that landed underneath this run)
|
||||
# rejecting this push makes the release impossible, so stop loudly and
|
||||
# say what to do instead of publishing an inconsistent artifact.
|
||||
if ! git push origin HEAD:master; then
|
||||
echo "::error::could not push the release commit for v${VERSION}: the" \
|
||||
"default branch rejected it (branch protection, or it moved during" \
|
||||
"this run). Nothing was tagged or published. Bump ${MANIFEST} to" \
|
||||
"${VERSION} manually, commit it, and re-run this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify the released manifest on master
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read the manifest from the REMOTE default branch, not from the
|
||||
# workspace: that is the copy HACS fetches, and it has to already carry
|
||||
# the version about to be tagged.
|
||||
git fetch --quiet origin master
|
||||
REMOTE_VERSION="$(git show "origin/master:${MANIFEST}" | jq -r '.version')"
|
||||
if [ "$REMOTE_VERSION" != "$VERSION" ]; then
|
||||
echo "::error::master's manifest says ${REMOTE_VERSION} but v${VERSION}" \
|
||||
"is being released — refusing to publish an inconsistent artifact." \
|
||||
"Re-run once master settles." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "master's manifest is ${REMOTE_VERSION}, matching v${VERSION}"
|
||||
|
||||
- name: Create tag
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@notexpectedyet.com"
|
||||
# HEAD is the commit the manifest was just verified on: the bumped one
|
||||
# when a write-back was needed, else the pushed commit itself. The tag
|
||||
# and the artifact therefore name the same version.
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
- name: Build the release zip
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd custom_components
|
||||
zip -r "../${ZIP_NAME}" nvidia_shield
|
||||
|
||||
- name: Publish the release
|
||||
if: steps.version.outputs.exists == 'no'
|
||||
if: steps.version.outputs.RELEASE == 'yes'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
TAG: ${{ steps.version.outputs.TAG }}
|
||||
BUMP: ${{ steps.version.outputs.BUMP }}
|
||||
SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Notes: subject lines of the commits in the released range.
|
||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${TAG}"
|
||||
# Notes: subject lines of the commits in the released range — the
|
||||
# commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
|
||||
# at the bot's own commit, so the range has to stop at its parent or every
|
||||
# release would open with "chore(release): vX [skip ci]". Without one the
|
||||
# tag is the pushed commit itself and belongs in the notes (t44 R1).
|
||||
if [ "$BUMP" = "yes" ]; then
|
||||
NOTES_END="${TAG}^"
|
||||
else
|
||||
RANGE="${TAG}"
|
||||
NOTES_END="${TAG}"
|
||||
fi
|
||||
PREV="$(git describe --tags --abbrev=0 "${NOTES_END}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${NOTES_END}"
|
||||
else
|
||||
RANGE="${NOTES_END}"
|
||||
fi
|
||||
{
|
||||
echo "Release ${TAG}"
|
||||
|
||||
@@ -9,7 +9,9 @@ This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/l
|
||||
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
||||
integrations.
|
||||
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
|
||||
> **Status:** early fork, actively rebuilt. Releases are date-based
|
||||
> (`vYYYY.MM.DD`, plus `vYYYY.MM.DD.N` for a second release in the same day) —
|
||||
> see [Releases](#releases) for how a push becomes one.
|
||||
> Expect rough edges until the MVP phases land. The build-out plan is in
|
||||
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||||
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||||
@@ -79,10 +81,15 @@ 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.
|
||||
**Standalone setup** offers both state sources at once — the Nvidia Remote
|
||||
Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
|
||||
Shield's address pre-filled when discovery found it. Either can be turned off,
|
||||
and each pairing step can be skipped (that transport stays off until you pair
|
||||
it), so ADB-only, protocol-only and fully passive entries are all one click
|
||||
apart. The `no_state_source` form error fires only when neither is enabled.
|
||||
|
||||
There is no other integration in the loop: one address, two transports, and the
|
||||
integration reads the device itself.
|
||||
|
||||
### Channel entry options
|
||||
|
||||
@@ -96,17 +103,32 @@ behavior). Both are consumed by `play_media` with
|
||||
|
||||
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:
|
||||
standard flow, and pairing stays interactive exactly as with UI setup. The UI is
|
||||
the primary path; the block exists so a provisioning/fleet setup can supply the
|
||||
address and the transport choice.
|
||||
|
||||
A complete, copy-pasteable block (every key is optional except an address):
|
||||
|
||||
```yaml
|
||||
nvidia_shield:
|
||||
- name: Living Room
|
||||
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
|
||||
adb_port: 5555
|
||||
- name: Living Room # the entry's title (default: NVIDIA Shield)
|
||||
host: 10.0.0.31 # the Shield's IP — the one address field
|
||||
adb_port: 5555 # optional; the standard ADB port
|
||||
adb_enabled: true # optional; the block's shape decides (see below)
|
||||
```
|
||||
|
||||
`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.
|
||||
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
|
||||
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
|
||||
the address is a prefill hint either way, and the PIN/ADB pairing still happens
|
||||
in the flow. `adb_enabled` defaults to what the block implies — an address under
|
||||
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
|
||||
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
|
||||
address are ignored with a warning.
|
||||
|
||||
The seed carries the address, the ADB toggle and the entry's name; everything
|
||||
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
|
||||
`adb_command` service) is set on the entry's **Configure** page after setup, or
|
||||
during the interactive flow.
|
||||
Duplicate guards: a block matching an existing entry's host (or its
|
||||
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||||
host. Removing the YAML block creates or deletes nothing — entries live
|
||||
@@ -126,24 +148,76 @@ 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
|
||||
## Replacing androidtv / androidtv_remote + the 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.
|
||||
This fork replaces the old `androidtv` media player, the `androidtv_remote`
|
||||
integration and the surrounding YAML glue — there is nothing to keep alongside
|
||||
it: the entry's own two transports cover the whole device.
|
||||
|
||||
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
|
||||
2. **Remove the old platform YAML** — the `media_player:` blocks using
|
||||
`platform: androidtv` or `platform: androidtv_remote`, any `universal` media
|
||||
player template that fanned them in, and the `remote:`/`switch:` shims you
|
||||
added around them.
|
||||
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
|
||||
feeding them are replaced by this integration (table below).
|
||||
4. **Delete the old integrations** in Settings > Devices & Services, then
|
||||
**restart** Home Assistant.
|
||||
5. **Add this integration** — Settings > Devices & Services > Add Integration >
|
||||
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
|
||||
then walk the pairing steps.
|
||||
6. **Update your automations and dashboards** to the new entities:
|
||||
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
|
||||
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
|
||||
|
||||
What each retired piece used to provide, and what covers it now:
|
||||
|
||||
| Retired piece | Covered by |
|
||||
|---------------|------------|
|
||||
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `idle` / `off`) |
|
||||
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
|
||||
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
|
||||
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
|
||||
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
|
||||
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
|
||||
|
||||
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
|
||||
comes from the ADB media session — the same tier every other app uses — so it
|
||||
needs no bridge either.
|
||||
|
||||
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
|
||||
full retirement mapping this section summarises.
|
||||
|
||||
## Releases
|
||||
|
||||
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
|
||||
- **Date-based versioning**: `vYYYY.MM.DD`, and `vYYYY.MM.DD.N` (N from 2) for a
|
||||
second release in the same UTC day.
|
||||
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
|
||||
run the release workflow (`.github/workflows/release.yml`):
|
||||
- A plain push publishes the current day's release (same-day pushes are deduped).
|
||||
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
|
||||
specific version's release before the date fallback kicks in.
|
||||
run the release workflow (`.gitea/workflows/release.yml`):
|
||||
- A plain push publishes today's release. If that date is already tagged it takes
|
||||
the lowest free `.N`, so a gap left by an earlier failure is reused rather than
|
||||
skipped.
|
||||
- Setting `version` in `custom_components/nvidia_shield/manifest.json` to an
|
||||
untagged value releases exactly that version instead.
|
||||
- **The release commit.** When the resolved version differs from the manifest, the
|
||||
workflow writes it back, commits `chore(release): v<VERSION> [skip ci]`, pushes it
|
||||
to `master`, and tags *that* commit. It has to do this: HACS compares the installed
|
||||
version against the manifest on the default branch, so a release that only ever
|
||||
existed as a tag is offered to nobody (v2026.10.09 shipped that way).
|
||||
- Because the tag names the bot commit, `git describe --tags` on a *developer*
|
||||
commit reports the **previous** release. That is expected, not a missing tag.
|
||||
- Bump it by hand instead and the tag lands on your own commit: set `version` in
|
||||
the manifest, commit, push. The workflow then has nothing to write back (it sees
|
||||
an untagged manifest version and tags your commit).
|
||||
- **`[skip ci]`**: the bot commit carries it, and the workflow's job additionally
|
||||
checks the commit *subject* (`chore(release): …`), so a write-back cannot start a
|
||||
second release. Both only recognise release commits — a normal commit whose message
|
||||
body merely mentions `[skip ci]` can still be skipped by Gitea's own handling, so
|
||||
keep the marker out of ordinary commit messages.
|
||||
- The workflow refuses to publish unless the default branch's manifest already carries
|
||||
the version it is about to tag, and fails loudly (with a "bump the manifest by hand"
|
||||
instruction and nothing tagged) if the write-back push is rejected, for example by
|
||||
branch protection.
|
||||
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
|
||||
installs. (No automatic mirroring — see the HACS note below.)
|
||||
|
||||
@@ -169,10 +243,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
|
||||
## Configuration
|
||||
|
||||
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
|
||||
2. Select the Android TV Remote entity for your Shield
|
||||
3. Enable ADB if you want now-playing from all apps
|
||||
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
|
||||
5. Optionally configure Jellyfin, receiver, power switch
|
||||
2. Give it a name and its address — the Shield's IP, pre-filled when discovery
|
||||
found the device
|
||||
3. Both transports are on by default: **Read state over ADB** (now-playing from
|
||||
every app) and **Add the Nvidia Remote Protocol** (power, availability, app
|
||||
push, volume readout). Uncheck what you do not want.
|
||||
4. If ADB is enabled, the next step pairs it — approve the prompt on the Shield
|
||||
5. The remote protocol pairs next — type the PIN shown on the Shield, or
|
||||
continue without pairing (that transport stays off until you pair it)
|
||||
6. Optionally configure Jellyfin, receiver, power switch
|
||||
|
||||
### ADB Pairing
|
||||
|
||||
@@ -189,7 +268,7 @@ service, and the discovered address is pre-filled on the setup form. The dedup
|
||||
rules are Home Assistant's own, and they differ with what the discovery carries:
|
||||
|
||||
- **with** the device MAC, a duplicate aborts as "already configured", and an
|
||||
existing entry's hosts are updated when the address changed;
|
||||
existing entry's address is updated when it changed;
|
||||
- **without** a MAC, the flow follows HA's stricter
|
||||
`_async_handle_discovery_without_unique_id` contract: **any** existing entry
|
||||
for this integration aborts the discovery, and only one discovery flow runs at
|
||||
@@ -205,7 +284,160 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
|
||||
- **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
|
||||
- **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
|
||||
transport on or off
|
||||
- **Shield address / ADB port** — the Shield's IP, and the ADB port if you
|
||||
changed it from 5555
|
||||
- **Command scripts** — one optional script per command, plus one for the remote
|
||||
entity; see below
|
||||
|
||||
### Command scripts (per-command overrides)
|
||||
|
||||
Some commands need hardware this integration deliberately does not drive: a speaker
|
||||
switch that owns the volume, an AVR input that has to move with the Shield, a mute
|
||||
that has to hit two devices. For those, the Configure page takes a **script per
|
||||
command**:
|
||||
|
||||
| Command | Runs when | Variables passed |
|
||||
| --- | --- | --- |
|
||||
| `turn_on`, `turn_off` | turn on/off, from the media player *or* the remote | — |
|
||||
| `volume_up`, `volume_down` | volume step | — |
|
||||
| `volume_set` | absolute volume (there is no native path — the protocol has VOLUME_UP/DOWN/MUTE only) | `level` |
|
||||
| `volume_mute` | mute/unmute | `is_volume_muted` |
|
||||
| `select_source` | source selection (the app list drives it natively; a script overrides) | `app_name` |
|
||||
|
||||
A command with a script calls the script **instead of** the protocol/ADB path, never
|
||||
both. Left unset — the default — every command behaves exactly as it did before, and
|
||||
clearing a selector on the Configure page restores that. Two of the seven appear as
|
||||
services only when there is something to do: `volume_set`, which has no native path at
|
||||
all, and `select_source`, which is offered once the integration has sources to offer
|
||||
(the app list below) — or a script.
|
||||
|
||||
The scripts are ordinary Home Assistant scripts. This block covers all three awkward
|
||||
cases: a speaker switch owning the volume, an AVR owning the input, and a mute that
|
||||
has to hit the switch as well.
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_volume: # select as "Volume set script"
|
||||
alias: Shield volume (speaker switch)
|
||||
sequence:
|
||||
- action: media_player.volume_set
|
||||
target:
|
||||
entity_id: media_player.living_room_speaker
|
||||
data:
|
||||
volume_level: "{{ level }}"
|
||||
shield_source: # select as "Select source script"
|
||||
alias: Shield source (AVR)
|
||||
sequence:
|
||||
- action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.avr
|
||||
data:
|
||||
source: "{{ app_name }}"
|
||||
shield_mute: # select as "Mute script"
|
||||
alias: Shield mute (speaker switch)
|
||||
sequence:
|
||||
- choose:
|
||||
- conditions: "{{ is_volume_muted }}"
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
default:
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.living_room_speaker
|
||||
```
|
||||
|
||||
Then set `script.shield_volume` as **Volume set script**, `script.shield_source` as
|
||||
**Select source script** and so on on the Configure page. Scripts are set there, not
|
||||
in the YAML seed above — the seed only carries the address and the transport choice.
|
||||
|
||||
A script that fails surfaces as a translated error on the service call that triggered
|
||||
it (the command is not retried over protocol/ADB), because a silent no-op is the one
|
||||
outcome that leaves you guessing.
|
||||
|
||||
### Sources (the app list in a dropdown)
|
||||
|
||||
The media player's source list is your **Apps** map — the same map the Browse tree and
|
||||
app-name resolution use. With no map configured it falls back to a small built-in list
|
||||
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||
something to show:
|
||||
|
||||
```yaml
|
||||
# a media-player card, or anything that reads source_list
|
||||
type: media-control
|
||||
entity: media_player.living_room_shield
|
||||
```
|
||||
|
||||
- **`source_list`** — the app names. They come from the map (in the order it lists
|
||||
them), so editing the map renames the dropdown entries.
|
||||
- **`source`** — the app currently on screen, resolved exactly like the Current App
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
- **`media_player.select_source`** — picks one **by name** and launches it over the
|
||||
protocol, the same path `play_media` uses for apps. A raw package id works too (that
|
||||
is what the Browse tree publishes), names match case-insensitively, and a name that
|
||||
is not in the list is an error rather than a silent no-op:
|
||||
|
||||
```yaml
|
||||
action: media_player.select_source
|
||||
target:
|
||||
entity_id: media_player.living_room_shield
|
||||
data:
|
||||
source: TiviMate
|
||||
```
|
||||
|
||||
Selecting a source is sometimes more than launching an app — an AVR input, a speaker
|
||||
switch. That is what the **Select source script** above is for: with one configured it
|
||||
replaces the native launch entirely.
|
||||
|
||||
#### Known limits (the Apps map)
|
||||
|
||||
The map is your own override, so two shapes are accepted rather than papered over:
|
||||
|
||||
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
|
||||
row — the first spelling — because name matching is case-insensitive. The shadowed
|
||||
app is not lost: the Browse tree still lists it under its own package id, and
|
||||
`media_player.select_source` accepts that id.
|
||||
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
|
||||
dropdown and the Browse tree, but `app_name`, `source` and the Current App sensor
|
||||
display the map value **verbatim** — so `source` can be a string that is not in
|
||||
`source_list`. A blank value falls through to the raw package instead.
|
||||
|
||||
### Remote command script
|
||||
|
||||
`remote.send_command` sends protocol keys: repeats, long presses (`START_LONG`/
|
||||
`END_LONG`) and inter-repeat delays. Some setups need that call to go somewhere else —
|
||||
an IR bridge, a macro, a second device — so one optional **Remote command script**
|
||||
replaces the whole key path when it is set. It receives the service's own parameters,
|
||||
named as they are on the service:
|
||||
|
||||
| Variable | Meaning |
|
||||
| --- | --- |
|
||||
| `command` | the list of key commands as submitted, e.g. `["HOME", "KEYCODE_DPAD_CENTER"]` |
|
||||
| `num_repeats` | how many times to send them (default `1`) |
|
||||
| `delay_secs` | the delay between repeats in seconds (default `0.0`) |
|
||||
| `hold_secs` | the requested long-press duration (default `0.0`) — the script owns the key semantics, so it decides what "hold" means |
|
||||
|
||||
```yaml
|
||||
script:
|
||||
shield_remote: # select as "Remote command script"
|
||||
alias: Shield remote (IR bridge)
|
||||
sequence:
|
||||
- action: remote.send_command
|
||||
target:
|
||||
entity_id: remote.living_room_bridge
|
||||
data:
|
||||
command: "{{ command }}" # a list: pass it through
|
||||
num_repeats: "{{ num_repeats }}"
|
||||
delay_secs: "{{ delay_secs }}"
|
||||
hold_secs: "{{ hold_secs }}"
|
||||
```
|
||||
|
||||
All four variables are always present, including their defaults, so a script can rely
|
||||
on the shape. Unset — the default — nothing changes: keys go out over the protocol
|
||||
exactly as before.
|
||||
|
||||
## Entities
|
||||
|
||||
|
||||
@@ -25,14 +25,22 @@ from .const import (
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SERVICE_ADB_COMMAND,
|
||||
)
|
||||
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
|
||||
from .helpers import (
|
||||
entry_host,
|
||||
protocol_cert_paths,
|
||||
resolve_adb_config,
|
||||
seed_adb_enabled,
|
||||
seed_host,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,9 +58,9 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
|
||||
"""
|
||||
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):
|
||||
if not seed_host(block):
|
||||
_LOGGER.warning(
|
||||
"nvidia_shield YAML block without protocol/adb host ignored: %s",
|
||||
"nvidia_shield YAML block without an address ignored: %s",
|
||||
block.get("name", "<unnamed>"),
|
||||
)
|
||||
continue
|
||||
@@ -84,15 +92,13 @@ def _yaml_seed_blocks(domain_config) -> list[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).
|
||||
# protocol-only block does NOT force ADB on. t41: the address is ONE
|
||||
# field (`host`, or a legacy protocol_host/adb_host).
|
||||
seed = {
|
||||
"name": name,
|
||||
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
|
||||
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
|
||||
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||
CONF_HOST: seed_host(block),
|
||||
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||
# 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"])
|
||||
@@ -178,15 +184,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
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.
|
||||
The address is the entry's own single field (t41). Nothing is derived from
|
||||
another integration.
|
||||
"""
|
||||
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
|
||||
return None
|
||||
|
||||
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
|
||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
_LOGGER.info(
|
||||
"Protocol enabled for entry %s but no host resolvable — continuing "
|
||||
@@ -205,23 +209,62 @@ async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate v1 entries to v2 (adds the remote-protocol fields).
|
||||
"""Migrate an older config entry to the current version.
|
||||
|
||||
ADB options carry over untouched; the ADB-host discovery fallback keeps
|
||||
serving as the protocol host until pairing populates it. Downgrades
|
||||
(version > 2) are rejected so HA offers a rollback.
|
||||
v1 → v2 added the remote-protocol fields (ADB options carried over
|
||||
untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
|
||||
|
||||
* the duplicated address fields collapse into the single CONF_HOST — the
|
||||
protocol host wins, else the ADB host, since both name the same Shield;
|
||||
* the androidtv-entity key goes. This integration owns the whole stack now:
|
||||
the entity it used to configure is neither a state source nor an app-name
|
||||
or power tier, so keeping the value would only mislead.
|
||||
|
||||
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
|
||||
"""
|
||||
if config_entry.version > 2:
|
||||
if config_entry.version > ENTRY_VERSION:
|
||||
return False
|
||||
if config_entry.version >= ENTRY_VERSION:
|
||||
return True
|
||||
|
||||
options = {**config_entry.options}
|
||||
data = {**config_entry.data}
|
||||
|
||||
if config_entry.version < 2:
|
||||
options = {**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
|
||||
|
||||
if not options.get(CONF_HOST):
|
||||
options[CONF_HOST] = (
|
||||
options.get(CONF_PROTOCOL_HOST)
|
||||
or options.get(CONF_ADB_HOST)
|
||||
or data.get(CONF_PROTOCOL_HOST)
|
||||
or data.get(CONF_ADB_HOST)
|
||||
or ""
|
||||
)
|
||||
|
||||
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
|
||||
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
|
||||
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
options.pop(legacy_key, None)
|
||||
data.pop(legacy_key, None)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=data, options=options, version=ENTRY_VERSION
|
||||
)
|
||||
if dropped_entity:
|
||||
_LOGGER.info(
|
||||
"Migrated %s config entry to version 2", config_entry.title
|
||||
"%s: config entry migrated to version %d — dropped the androidtv "
|
||||
"entity %s (this integration reads the Shield directly; the entry "
|
||||
"needs no external media_player)",
|
||||
config_entry.title,
|
||||
ENTRY_VERSION,
|
||||
dropped_entity,
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Migrated %s config entry to version %d",
|
||||
config_entry.title,
|
||||
ENTRY_VERSION,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -11,13 +11,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import discover_adb_host
|
||||
from .helpers import entry_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,9 +53,9 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Attempt ADB connection. The Shield will show a pairing prompt."""
|
||||
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
# t41: the entry's own single address — never derived from another
|
||||
# integration's entity.
|
||||
host = entry_host(self._entry)
|
||||
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||
|
||||
if not host:
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
@@ -16,28 +15,32 @@ 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_COMMAND_SCRIPTS,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_LOGCAT_EVENTS,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
CONF_RECEIVER_INPUT,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
CONF_SKIP_PAIRING,
|
||||
DEFAULT_ADB_PORT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ENTRY_VERSION,
|
||||
SCRIPT_COMMANDS,
|
||||
SCRIPT_FIELD_PREFIX,
|
||||
command_scripts,
|
||||
)
|
||||
from .helpers import discover_adb_host, protocol_cert_paths
|
||||
from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,34 +56,6 @@ 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)
|
||||
@@ -169,11 +144,12 @@ def _host_field_key(field: str, host: str):
|
||||
|
||||
|
||||
def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||
"""User-step schema — the androidtv entity is optional (standalone).
|
||||
"""User-step schema — every field belongs to THIS integration (t41).
|
||||
|
||||
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a
|
||||
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it
|
||||
reads as an advanced setting rather than required knowledge (t33 F-D).
|
||||
``defaults`` pre-fills the name and the ONE address (t41) from a zeroconf
|
||||
discovery event or a YAML seed hint; the ADB port is pre-filled with the
|
||||
standard 5555 so it reads as an advanced setting rather than required
|
||||
knowledge (t33 F-D).
|
||||
"""
|
||||
defaults = defaults or {}
|
||||
host = str(defaults.get("host") or "")
|
||||
@@ -182,7 +158,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||
port = DEFAULT_ADB_PORT
|
||||
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"],
|
||||
}
|
||||
@@ -193,39 +168,63 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
|
||||
|
||||
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
|
||||
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||
# the code's own fallback for this flag is True (async_step_user) — the
|
||||
# rendered checkbox must agree with it
|
||||
# t41: both transports are OURS and both are ON by default — the user opts
|
||||
# out (or skips pairing) rather than opting in. The rendered checkboxes must
|
||||
# agree with the code's fallbacks in async_step_user / _create_entry, so all
|
||||
# three carry True (t33 F-A: a default the schema cannot see is a bug).
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=True)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
|
||||
schema_dict[_host_field_key(CONF_ADB_HOST, host)] = str
|
||||
schema_dict[_host_field_key(CONF_HOST, host)] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, host)] = str
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=True)] = bool
|
||||
return schema_dict
|
||||
|
||||
|
||||
class _ClearingEntitySelector(selector.EntitySelector):
|
||||
"""An entity selector that accepts a CLEARED field (t47 F1).
|
||||
|
||||
Clearing an optional entity field used to depend on the frontend OMITTING the
|
||||
key. It does not have to: a submission can carry ``''`` or ``None``, and real
|
||||
Home Assistant's EntitySelector rejects both — the reviewer's real-HA run
|
||||
produced "Schema validation failed at script_turn_on" from an explicit clear.
|
||||
That made clearing look like a broken form rather than a choice, on every
|
||||
entity field of this page (the script selectors are the newest, not the only
|
||||
ones).
|
||||
|
||||
Returning the empty value unchanged lets the flow read it as "no selection",
|
||||
while a real entity id still goes through the parent's validation. The class
|
||||
stays an EntitySelector, so the frontend still renders an entity picker.
|
||||
"""
|
||||
|
||||
def __call__(self, data):
|
||||
if data is None or (isinstance(data, str) and not data.strip()):
|
||||
return ""
|
||||
return super().__call__(data)
|
||||
|
||||
|
||||
def _build_selectors():
|
||||
"""Build reusable selector instances."""
|
||||
return {
|
||||
"androidtv": selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
|
||||
),
|
||||
"media_player": selector.EntitySelector(
|
||||
"media_player": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain="media_player")
|
||||
),
|
||||
"receiver": selector.EntitySelector(
|
||||
"receiver": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
|
||||
),
|
||||
"power": selector.EntitySelector(
|
||||
"power": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
|
||||
),
|
||||
# t44: a script entity per command override
|
||||
"script": _ClearingEntitySelector(
|
||||
selector.EntitySelectorConfig(domain="script")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Nvidia Shield."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = ENTRY_VERSION
|
||||
|
||||
def __init__(self):
|
||||
self._user_data = {}
|
||||
@@ -243,51 +242,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""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.
|
||||
We own the whole stack (t41), so the two state sources are ours: the
|
||||
Nvidia Remote Protocol (pairing) and ADB media-session mode
|
||||
(CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
|
||||
neither is enabled.
|
||||
"""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY)
|
||||
|
||||
has_source = (
|
||||
bool(androidtv_entity)
|
||||
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
|
||||
or bool(user_input.get(CONF_ADB_ENABLED))
|
||||
has_source = bool(user_input.get(CONF_PROTOCOL_ENABLED)) or bool(
|
||||
user_input.get(CONF_ADB_ENABLED)
|
||||
)
|
||||
if not has_source:
|
||||
errors["base"] = "no_state_source"
|
||||
return self._show_user_form(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()
|
||||
# t41: nothing here derives a unique_id from another integration.
|
||||
# The entry's identity is the protocol MAC learned during pairing
|
||||
# (`async_set_unique_id(mac)` in the pairing step) or the config
|
||||
# entry itself — never a renameable entity id (t29 G2's
|
||||
# atv-device derivation went with the entity path).
|
||||
|
||||
# Auto-detect Jellyfin if not manually specified
|
||||
# Auto-detect Jellyfin from the names setup already knows: the
|
||||
# entry name the user typed, or the device name from discovery.
|
||||
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)
|
||||
if androidtv_entity
|
||||
else None
|
||||
)
|
||||
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
|
||||
|
||||
for name in [device_name, atv_name]:
|
||||
for name in (user_input.get("name", DEFAULT_NAME), self._discovered_name):
|
||||
if not name:
|
||||
continue
|
||||
found = _find_jellyfin_entity(self.hass, name)
|
||||
@@ -298,17 +278,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._user_data = {
|
||||
"name": user_input.get("name", DEFAULT_NAME),
|
||||
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, ""),
|
||||
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
|
||||
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
|
||||
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, True),
|
||||
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
|
||||
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
|
||||
CONF_HOST: str(user_input.get(CONF_HOST) or "").strip(),
|
||||
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, ""),
|
||||
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
|
||||
}
|
||||
|
||||
# If ADB is enabled, go to pairing step
|
||||
@@ -390,22 +368,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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, "")
|
||||
# Known device — IP change (t21 F2, collapsed by t41): there is
|
||||
# ONE address field now, so the discovery address simply IS the
|
||||
# entry's address. The per-host dance (blank self-heal / stale
|
||||
# protocol host / deliberately different ADB host) only existed
|
||||
# because the address was stored twice; it went with the
|
||||
# duplication.
|
||||
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 entry_host(entry) != host:
|
||||
options[CONF_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
|
||||
@@ -433,25 +404,26 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self.async_step_user()
|
||||
|
||||
def _create_entry(self):
|
||||
"""Create the config entry from stored user data."""
|
||||
"""Create the config entry from stored user data.
|
||||
|
||||
Everything the integration needs lives in the OPTIONS (t41: one host
|
||||
field among them); ``data`` stays empty because no value has to be
|
||||
immutable.
|
||||
"""
|
||||
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=entry_data,
|
||||
data={},
|
||||
options={
|
||||
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
|
||||
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
|
||||
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
|
||||
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
|
||||
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
|
||||
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, True),
|
||||
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
|
||||
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
|
||||
CONF_HOST: d.get(CONF_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, ""),
|
||||
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, True),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -461,9 +433,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
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()
|
||||
|
||||
def _discard_protocol_client(self) -> None:
|
||||
"""Drop a pairing client whose session became void (host corrected)."""
|
||||
@@ -478,12 +447,20 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Certificates live under ``.storage/<domain>/`` (core androidtv_remote
|
||||
helpers.py pattern) and are shared between pairing and runtime. Returns
|
||||
None when no host is resolvable or ``androidtvremote2`` is missing —
|
||||
the caller turns that into an error key, never a crash.
|
||||
``(client, None)`` or ``(None, error_key)`` — no host, a missing
|
||||
``androidtvremote2`` and an unreachable Shield are all keys the form can
|
||||
show, never an exception out of the step.
|
||||
|
||||
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below,
|
||||
and the protocol transport is ON by default, so an asleep, rebooting or
|
||||
mis-addressed Shield is the ordinary case rather than an edge one. Left
|
||||
uncaught it escaped the config flow entirely, and the protocol_pair form
|
||||
— the address field AND the "continue without pairing" escape — never
|
||||
rendered.
|
||||
"""
|
||||
host = self._protocol_host()
|
||||
host = self._host()
|
||||
if not host:
|
||||
return None
|
||||
return None, "protocol_no_host"
|
||||
certfile, keyfile = await self.hass.async_add_executor_job(
|
||||
protocol_cert_paths, self.hass
|
||||
)
|
||||
@@ -496,19 +473,26 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if client is None:
|
||||
# build_protocol_client already logged the missing library;
|
||||
# pairing cannot start, and the form still offers the skip path
|
||||
return None
|
||||
await client.async_generate_cert_if_missing()
|
||||
await client.async_start_pairing()
|
||||
return client
|
||||
return None, "protocol_unavailable"
|
||||
try:
|
||||
await client.async_generate_cert_if_missing()
|
||||
await client.async_start_pairing()
|
||||
except Exception as err: # noqa: BLE001 - the library's error IS the finding
|
||||
_LOGGER.warning(
|
||||
"protocol pairing could not start at %s (%s: %s) — showing the "
|
||||
"form so the address can be corrected or pairing skipped",
|
||||
host,
|
||||
type(err).__name__,
|
||||
err,
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
client.disconnect()
|
||||
return None, "protocol_unreachable"
|
||||
return client, None
|
||||
|
||||
async def _async_begin_protocol_pairing(self):
|
||||
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
|
||||
if not self._protocol_host():
|
||||
return None, "protocol_no_host"
|
||||
client = await self._start_protocol_pairing()
|
||||
if client is None:
|
||||
return None, "protocol_unavailable"
|
||||
return client, None
|
||||
return await self._start_protocol_pairing()
|
||||
|
||||
def _show_protocol_pair_form(self, errors=None):
|
||||
"""protocol_pair form: the PIN, the address, and the skip escape.
|
||||
@@ -522,7 +506,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PAIRING_CODE): str,
|
||||
_host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str,
|
||||
_host_field_key(CONF_HOST, self._host()): str,
|
||||
vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
|
||||
}
|
||||
),
|
||||
@@ -582,12 +566,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input.get(CONF_SKIP_PAIRING):
|
||||
return await self._async_skip_protocol_pairing()
|
||||
|
||||
typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip()
|
||||
if typed_host and typed_host != (self._protocol_host() or ""):
|
||||
typed_host = str(user_input.get(CONF_HOST) or "").strip()
|
||||
if typed_host and typed_host != self._host():
|
||||
# A host typed HERE is authoritative — the user step is gone, so
|
||||
# the session started against the old address is dropped and the
|
||||
# Shield is asked for a NEW code at the corrected address (t35 F1).
|
||||
self._user_data[CONF_PROTOCOL_HOST] = typed_host
|
||||
self._user_data[CONF_HOST] = typed_host
|
||||
self._discard_protocol_client()
|
||||
|
||||
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
|
||||
@@ -636,19 +620,17 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
block = dict(data or {})
|
||||
name = block.get("name") or DEFAULT_NAME
|
||||
# the block's address comes from `host`, or from the legacy keys
|
||||
block_host = seed_host(block)
|
||||
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'}"
|
||||
seed_unique_id = f"yaml:{name}:{block_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
|
||||
)
|
||||
ehost = entry_host(existing)
|
||||
if existing.unique_id == seed_unique_id or (
|
||||
yhost and ehost == yhost
|
||||
block_host and ehost == block_host
|
||||
):
|
||||
_LOGGER.info(
|
||||
"nvidia_shield YAML seed for %r matches an existing "
|
||||
@@ -664,18 +646,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# 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, ""),
|
||||
# t32 F4's shape rule on the t41 keys: an address names the Shield,
|
||||
# and an explicit adb_enabled says otherwise
|
||||
CONF_ADB_ENABLED: seed_adb_enabled(block),
|
||||
CONF_HOST: block_host,
|
||||
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
|
||||
}
|
||||
# t33 F-A: the seed host feeds the same user-step prefill as a
|
||||
# zeroconf discovery, but WITHOUT the discovery announcement — the
|
||||
# address came from configuration.yaml, not from the network.
|
||||
self._seed_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[
|
||||
CONF_ADB_HOST
|
||||
]
|
||||
self._seed_host = block_host
|
||||
return await self.async_step_user(None)
|
||||
|
||||
async def async_step_reauth(self, entry_data):
|
||||
@@ -696,7 +676,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._user_data = {
|
||||
"name": entry.title,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||||
CONF_HOST: entry_host(entry),
|
||||
}
|
||||
return await self.async_step_protocol_pair() # first visit re-pairs
|
||||
return self.async_show_form(
|
||||
@@ -722,12 +702,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# the entry already knows (and _create_entry is never reached here).
|
||||
self._user_data = {
|
||||
"name": entry.title,
|
||||
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
|
||||
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
|
||||
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""),
|
||||
CONF_HOST: entry_host(entry),
|
||||
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
|
||||
}
|
||||
return await self.async_step_protocol_pair()
|
||||
|
||||
@@ -744,7 +722,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"options": {
|
||||
**entry.options,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: self._protocol_host() or "",
|
||||
CONF_HOST: self._host(),
|
||||
},
|
||||
}
|
||||
if mac:
|
||||
@@ -767,17 +745,25 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
options={
|
||||
**entry.options,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""),
|
||||
CONF_HOST: self._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."""
|
||||
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
def _host(self) -> str:
|
||||
"""The Shield's address as this flow knows it (t41: ONE field).
|
||||
|
||||
What the user typed (or a seed carried) lives in ``_user_data``; the
|
||||
discovery/seed hints stay as fallbacks for a form that was rendered with
|
||||
a pre-filled field and submitted unchanged.
|
||||
"""
|
||||
return str(
|
||||
self._user_data.get(CONF_HOST)
|
||||
or self._discovered_host
|
||||
or self._seed_host
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _show_adb_pair_form(self, errors=None):
|
||||
"""adb_pair form: the address/port (fixable here) and the skip escape.
|
||||
@@ -790,7 +776,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
step_id="adb_pair",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
_host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str,
|
||||
_host_field_key(CONF_HOST, self._host()): str,
|
||||
vol.Optional(
|
||||
CONF_ADB_PORT,
|
||||
default=self._user_data.get(
|
||||
@@ -821,10 +807,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# re-adding the integration.
|
||||
return await self._async_after_pairing()
|
||||
|
||||
typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip()
|
||||
typed_host = str(user_input.get(CONF_HOST) or "").strip()
|
||||
if typed_host:
|
||||
# a host typed HERE is authoritative (the user step is gone)
|
||||
self._user_data[CONF_ADB_HOST] = typed_host
|
||||
self._user_data[CONF_HOST] = typed_host
|
||||
|
||||
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
|
||||
try:
|
||||
@@ -833,7 +819,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
port = DEFAULT_ADB_PORT
|
||||
self._user_data[CONF_ADB_PORT] = port
|
||||
|
||||
host = self._resolve_adb_host()
|
||||
host = self._host()
|
||||
if not host:
|
||||
errors["base"] = "adb_no_host"
|
||||
else:
|
||||
@@ -883,6 +869,11 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
"""Single-page options."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
# t47 F1: a field submitted as None means "not supplied" — only an
|
||||
# explicit empty value ('' or a cleared selector) clears a setting, so
|
||||
# the two cannot be confused. Note the schema itself already tolerates
|
||||
# the empty value (see _ClearingEntitySelector).
|
||||
user_input = {key: value for key, value in user_input.items() if value is not None}
|
||||
# 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:
|
||||
@@ -893,6 +884,23 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
delay = None
|
||||
if delay is not None and not (0.05 <= delay <= 1.0):
|
||||
errors["base"] = "invalid_digit_delay"
|
||||
# t44: one selector per command, stored as a single dict option so
|
||||
# the service layer reads one shape. Popped here because _options_dict
|
||||
# is updated with the rest of user_input verbatim below.
|
||||
scripts = {
|
||||
command: script
|
||||
for command in SCRIPT_COMMANDS
|
||||
if (
|
||||
script := str(
|
||||
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
|
||||
).strip()
|
||||
)
|
||||
}
|
||||
# t47: one optional script for the remote entity — a plain string
|
||||
# option, unlike the per-command dict above
|
||||
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||
if remote_script is not None:
|
||||
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
|
||||
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
|
||||
icons: dict[str, str] = {}
|
||||
if app_map_text is None:
|
||||
@@ -912,6 +920,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
# truth, so a save without icons clears them (and a
|
||||
# single-column map stays valid).
|
||||
self._options_dict[CONF_APP_ICON_MAP] = icons
|
||||
# an empty dict clears every override (the editor is the truth)
|
||||
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
|
||||
return self.async_create_entry(title="", data=self._options_dict)
|
||||
|
||||
sel = _build_selectors()
|
||||
@@ -936,6 +946,18 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
|
||||
schema_dict[recv_key] = sel["receiver"]
|
||||
|
||||
# t44: command script overrides — the escape hatch for hardware we do not
|
||||
# drive (a speaker switch owning the volume). Unset = native path as before.
|
||||
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
|
||||
|
||||
configured_scripts = command_scripts(self._options_dict)
|
||||
suggested_scripts = {}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
field = f"{SCRIPT_FIELD_PREFIX}{command}"
|
||||
schema_dict[vol.Optional(field)] = sel["script"]
|
||||
if configured_scripts.get(command):
|
||||
suggested_scripts[field] = configured_scripts[command]
|
||||
|
||||
# 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
|
||||
@@ -974,12 +996,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
pwr_key = vol.Optional(CONF_POWER_ENTITY)
|
||||
schema_dict[pwr_key] = sel["power"]
|
||||
|
||||
# ADB options
|
||||
# Transport toggles (t41: both transports are ours; the skip paths and
|
||||
# this page are the way to turn either one off)
|
||||
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
|
||||
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
|
||||
|
||||
# ADB host/port (optional overrides, shown when ADB is enabled)
|
||||
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
|
||||
# The ONE address field (t41), plus the ADB port
|
||||
schema_dict[vol.Optional(CONF_HOST)] = str
|
||||
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
|
||||
|
||||
# t29 G3b: suggested values come from the entry options via the
|
||||
@@ -987,7 +1011,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(schema_dict), self._options_dict
|
||||
vol.Schema(schema_dict),
|
||||
{**self._options_dict, **suggested_scripts},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401
|
||||
|
||||
DOMAIN = "nvidia_shield"
|
||||
|
||||
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
|
||||
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
|
||||
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
|
||||
# runtime code reads them.
|
||||
CONF_HOST = "host"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_PROTOCOL_HOST = "protocol_host"
|
||||
|
||||
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
|
||||
# path it configured is gone), never read at runtime.
|
||||
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
|
||||
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
||||
CONF_RECEIVER_ENTITY = "receiver_entity"
|
||||
@@ -19,12 +29,10 @@ CONF_RECEIVER_INPUT = "receiver_input"
|
||||
CONF_POWER_ENTITY = "power_entity"
|
||||
CONF_ADB_ENABLED = "adb_enabled"
|
||||
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
||||
CONF_ADB_HOST = "adb_host"
|
||||
CONF_ADB_PORT = "adb_port"
|
||||
|
||||
# Remote-protocol transport (androidtvremote2) — Phase 2
|
||||
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
||||
CONF_PROTOCOL_HOST = "protocol_host"
|
||||
CONF_PAIRING_CODE = "pairing_code"
|
||||
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
|
||||
# steps — the entry still finishes (passive mode) instead of stranding the
|
||||
@@ -47,6 +55,11 @@ STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
||||
|
||||
DEFAULT_NAME = "Nvidia Shield"
|
||||
|
||||
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
|
||||
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
|
||||
# androidtv-entity key. One constant so the flow and the migration cannot drift.
|
||||
ENTRY_VERSION = 3
|
||||
|
||||
# Emotiva DAG attribute we read to get available inputs
|
||||
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
||||
|
||||
@@ -147,3 +160,72 @@ SYSTEM_MEDIA_PACKAGES = [
|
||||
"com.android.tv.settings",
|
||||
"com.nvidia.diagtools",
|
||||
]
|
||||
|
||||
|
||||
# t44: per-command script overrides — the escape hatch for hardware this
|
||||
# integration deliberately does not drive itself (a speaker switch that owns the
|
||||
# volume, an AVR input, a mute that has to hit two devices). One script per
|
||||
# command: when one is configured, that command calls the script INSTEAD of the
|
||||
# native protocol/ADB path, and with none configured nothing changes at all.
|
||||
CONF_COMMAND_SCRIPTS = "command_scripts"
|
||||
SCRIPT_COMMANDS = (
|
||||
"turn_on",
|
||||
"turn_off",
|
||||
"volume_up",
|
||||
"volume_down",
|
||||
"volume_set",
|
||||
"volume_mute",
|
||||
"select_source",
|
||||
)
|
||||
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
|
||||
SCRIPT_FIELD_PREFIX = "script_"
|
||||
#: Variables a command hands its script (HA script variables). volume_mute passes
|
||||
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
|
||||
SCRIPT_VAR_LEVEL = "level"
|
||||
SCRIPT_VAR_APP_NAME = "app_name"
|
||||
SCRIPT_VAR_MUTED = "is_volume_muted"
|
||||
|
||||
|
||||
def command_scripts(options) -> dict[str, str]:
|
||||
"""The script configured per command, normalized (t44).
|
||||
|
||||
The ONE place the stored option's shape is read: the options flow, the media
|
||||
player and the remote entity all go through it. Options round-trip through HA
|
||||
storage and can be hand-edited, so a value that is not a non-empty string for a
|
||||
known command is dropped rather than handed to the service layer.
|
||||
"""
|
||||
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
scripts: dict[str, str] = {}
|
||||
for command in SCRIPT_COMMANDS:
|
||||
script = raw.get(command)
|
||||
if isinstance(script, str) and script.strip():
|
||||
scripts[command] = script.strip()
|
||||
return scripts
|
||||
|
||||
|
||||
# t47: ONE optional script for the remote entity's send_command. It replaces the
|
||||
# whole key path (repeats, hold, delays included) rather than per-key overrides,
|
||||
# because a remote command is a sequence: an IR bridge, a macro or a second device
|
||||
# needs the call, not individual keys.
|
||||
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
|
||||
#: Variables remote.send_command hands its script — the names are the service's own
|
||||
#: parameter names, and they are documented in the README and pinned in tests.
|
||||
SCRIPT_VAR_COMMAND = "command"
|
||||
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
|
||||
SCRIPT_VAR_DELAY_SECS = "delay_secs"
|
||||
SCRIPT_VAR_HOLD_SECS = "hold_secs"
|
||||
|
||||
|
||||
def remote_command_script(options) -> str | None:
|
||||
"""The script configured for remote.send_command, or None (t47).
|
||||
|
||||
Same defensive read as ``command_scripts``: options round-trip through HA
|
||||
storage and can be hand-edited, so anything that is not a non-empty string is
|
||||
"not configured" rather than a service call to nowhere.
|
||||
"""
|
||||
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
|
||||
if isinstance(script, str) and script.strip():
|
||||
return script.strip()
|
||||
return None
|
||||
@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.redact import async_redact_data
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .media_player import entity_available
|
||||
|
||||
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
|
||||
_REDACTED = "**REDACTED**"
|
||||
@@ -81,7 +82,14 @@ async def async_get_config_entry_diagnostics(
|
||||
if isinstance(manager, ShieldConnectionManager):
|
||||
payload["store"] = manager.store_snapshot()
|
||||
payload["transport"] = {
|
||||
"adb": {"connected": manager.connected, "awake": manager.awake},
|
||||
# t40: the ladder's verdict, from the same helper the entities use,
|
||||
# so a support dump answers "why is my entity unavailable?".
|
||||
"entity_available": entity_available(manager),
|
||||
"adb": {
|
||||
"configured": manager.available,
|
||||
"connected": manager.connected,
|
||||
"awake": manager.awake,
|
||||
},
|
||||
"protocol": {
|
||||
"active": manager.protocol_active,
|
||||
"available": manager.is_available,
|
||||
|
||||
@@ -4,12 +4,13 @@ import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_HOST,
|
||||
DEFAULT_ADB_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -42,31 +43,51 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
|
||||
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
|
||||
|
||||
|
||||
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 entry_host(entry) -> str:
|
||||
"""The Shield's address for an entry (t41: ONE field).
|
||||
|
||||
``options`` is the source of truth; ``data`` is read as a fallback so an
|
||||
entry created by an older flow (or a hand-written seed) still resolves.
|
||||
"""
|
||||
options = getattr(entry, "options", None) or {}
|
||||
data = getattr(entry, "data", None) or {}
|
||||
return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip()
|
||||
|
||||
|
||||
def seed_host(block: dict) -> str:
|
||||
"""The address a YAML seed block carries (t41).
|
||||
|
||||
``host`` is the current key; the legacy ``protocol_host`` / ``adb_host``
|
||||
stay accepted so an existing configuration.yaml keeps seeding, protocol
|
||||
first (that transport is the one that cannot fall back to anything else).
|
||||
"""
|
||||
for key in (CONF_HOST, CONF_PROTOCOL_HOST, CONF_ADB_HOST):
|
||||
value = str((block or {}).get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def seed_adb_enabled(block: dict) -> bool:
|
||||
"""Does a seed block ask for ADB? (t32 F4's shape rule, t41's keys).
|
||||
|
||||
An explicit ``adb_enabled`` wins. Otherwise an address that names the Shield
|
||||
— the current ``host`` key or the legacy ``adb_host`` — turns ADB on, while
|
||||
a protocol-only block leaves it off.
|
||||
"""
|
||||
block = block or {}
|
||||
if CONF_ADB_ENABLED in block:
|
||||
return bool(block[CONF_ADB_ENABLED])
|
||||
return bool(block.get(CONF_HOST) or block.get(CONF_ADB_HOST))
|
||||
|
||||
|
||||
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
|
||||
"""Resolve host/port/key_path for the entry's ADB connection, or None.
|
||||
|
||||
Priority: explicit ADB host/port options > host from the AndroidTV
|
||||
entity's own config entry.
|
||||
Priority: the entry's own address (t41: no other integration is consulted)
|
||||
and its ADB port option.
|
||||
"""
|
||||
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
|
||||
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
|
||||
)
|
||||
host = entry_host(entry)
|
||||
if not host:
|
||||
return None
|
||||
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"adb-shell>=0.4.0",
|
||||
"androidtvremote2>=0.2.0"
|
||||
],
|
||||
"version": "2026.10.08",
|
||||
"version": "2026.10.11.2",
|
||||
"zeroconf": [
|
||||
"_androidtvremote2._tcp.local."
|
||||
],
|
||||
|
||||
@@ -9,8 +9,9 @@ State architecture:
|
||||
- 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
|
||||
- Commands: protocol key push → ADB keyevent → external androidtv_remote
|
||||
(the last tier is transitional; removed in Phase 4)
|
||||
- Commands: a configured per-command script (t44) → protocol key push → ADB
|
||||
keyevent. The external androidtv_remote entity tier that used to back this is
|
||||
gone — t41 removed it with the entity — so a command has exactly one owner.
|
||||
|
||||
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
|
||||
1. User presses pause → intent recorded → UI shows paused immediately
|
||||
@@ -53,7 +54,6 @@ from .const import (
|
||||
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,
|
||||
@@ -66,7 +66,11 @@ from .const import (
|
||||
DOMAIN,
|
||||
PROTOCOL_MEDIA_KEYS,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_APP_NAME,
|
||||
SCRIPT_VAR_LEVEL,
|
||||
SCRIPT_VAR_MUTED,
|
||||
TIVIMATE_PACKAGE,
|
||||
command_scripts,
|
||||
)
|
||||
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
|
||||
from .jellyfin import fetch_session_media_info
|
||||
@@ -102,6 +106,44 @@ _PLAYBACK_FEATURES = (
|
||||
)
|
||||
|
||||
|
||||
async def async_call_script(
|
||||
hass: HomeAssistant, script: str, action: str, variables: dict | None = None
|
||||
) -> None:
|
||||
"""Call a configured script with variables, surfacing failures (t44/t47).
|
||||
|
||||
``blocking=True`` so a script that fails surfaces HERE: a command that cannot
|
||||
run must be a visible, translated error rather than a silent no-op. Module-level
|
||||
so the media player and the remote entity share one implementation.
|
||||
"""
|
||||
data: dict = {"entity_id": script}
|
||||
if variables:
|
||||
data["variables"] = variables
|
||||
try:
|
||||
await hass.services.async_call("script", "turn_on", data, blocking=True)
|
||||
except Exception as err: # noqa: BLE001 - re-raised as a user-facing error
|
||||
_LOGGER.error("the script for %s (%s) failed: %s", action, script, err)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_script_failed",
|
||||
translation_placeholders={
|
||||
"action": action,
|
||||
"script": script,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def async_run_command_script(
|
||||
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
|
||||
) -> bool:
|
||||
"""Run the script configured for ``command``; True when one handled it (t44)."""
|
||||
script = command_scripts(options).get(command)
|
||||
if not script:
|
||||
return False
|
||||
await async_call_script(hass, script, command, variables)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
@@ -121,8 +163,12 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
|
||||
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:
|
||||
# t41: the entity path is gone, so the SHAPE of the stale id is the marker.
|
||||
# Legacy entries (pre-t29 G2) carry the androidtv entity id as unique_id;
|
||||
# every other shape (a MAC, the config-entry id, a yaml: seed id) is left
|
||||
# exactly as it is.
|
||||
legacy = entry.unique_id or ""
|
||||
if not legacy.startswith("media_player."):
|
||||
return
|
||||
manager = getattr(entry, "runtime_data", None)
|
||||
new_id = None
|
||||
@@ -153,6 +199,61 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
|
||||
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
|
||||
|
||||
|
||||
def entity_available(manager: ShieldConnectionManager | None) -> bool:
|
||||
"""Is ANY usable transport healthy for this entry? (t40 live install)
|
||||
|
||||
The single ladder behind ``media_player.available``, ``remote.available``
|
||||
and the diagnostics payload, so the three cannot disagree:
|
||||
|
||||
* the protocol transport is wired and has not reported the device
|
||||
unreachable — it serves power/app/volume even when ADB is down;
|
||||
* or ADB is connected (live state data);
|
||||
* or fresh wake data exists (the poller has spoken at least once);
|
||||
* or the manager is passive (no ADB configured at all): nothing to
|
||||
transport is not a broken transport.
|
||||
|
||||
Unavailable only when a CONFIGURED transport is actively down and no usable
|
||||
data exists. The t5-F2 rule ("an ADB manager that is down means unreliable
|
||||
state") took the whole entity down while a healthy paired protocol transport
|
||||
was still serving commands — the reported defect.
|
||||
"""
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return True
|
||||
if manager.protocol_active and manager.is_available is not False:
|
||||
return True
|
||||
if manager.connected:
|
||||
return True
|
||||
if manager.awake is not None:
|
||||
return True
|
||||
return not manager.available
|
||||
|
||||
|
||||
def resolve_app_name(
|
||||
package: str | None,
|
||||
app_name_map,
|
||||
app_label,
|
||||
) -> str | None:
|
||||
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
|
||||
|
||||
The single chain behind ``media_player.app_name`` and the Current App
|
||||
sensor:
|
||||
|
||||
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
|
||||
2. the cached device-manifest label (``manager.app_label`` — a cache miss
|
||||
enqueues the on-demand fetch and falls through);
|
||||
3. the raw package text — a bare or unknown package falls back to itself,
|
||||
never to None.
|
||||
"""
|
||||
if package:
|
||||
mapped = (app_name_map or {}).get(package)
|
||||
if mapped:
|
||||
return mapped
|
||||
label = app_label(package) if app_label is not None else None
|
||||
if label:
|
||||
return label
|
||||
return package or None
|
||||
|
||||
|
||||
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
@@ -164,7 +265,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
self._entry = entry
|
||||
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
|
||||
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
|
||||
self._unsub_listeners: list = []
|
||||
|
||||
@@ -195,15 +295,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""The bridge entities stay functional without ADB (passive manager).
|
||||
"""Available while ANY usable transport is healthy (t40 live install).
|
||||
|
||||
Unavailable only while an active ADB manager is down — its data is
|
||||
unreliable until the watchdog reconnects (t5-F2).
|
||||
ADB enabled but unreachable no longer takes the entity down: a paired,
|
||||
available protocol transport still serves power/app/volume. The ladder
|
||||
is shared with the remote and diagnostics (``entity_available``).
|
||||
"""
|
||||
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
|
||||
return entity_available(getattr(self._entry, "runtime_data", None))
|
||||
|
||||
@property
|
||||
def _jellyfin_entity(self) -> str | None:
|
||||
@@ -275,11 +373,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
self._on_manager_update
|
||||
)
|
||||
|
||||
entities = [self._androidtv_entity]
|
||||
if self._jellyfin_entity:
|
||||
entities.append(self._jellyfin_entity)
|
||||
if self._power_entity:
|
||||
entities.append(self._power_entity)
|
||||
entities = [
|
||||
entity_id
|
||||
for entity_id in (self._jellyfin_entity, self._power_entity)
|
||||
if entity_id
|
||||
]
|
||||
self._unsub_listeners.append(
|
||||
async_track_state_change_event(
|
||||
self.hass, entities, self._on_source_change
|
||||
@@ -379,9 +477,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
self._manager.send_media_command(intent.adb_command)
|
||||
)
|
||||
else:
|
||||
self._track_task(
|
||||
self._cmd(intent.command)
|
||||
# t41: neither of OUR transports served this command (both were
|
||||
# down when it was sent), so there is nothing to re-send. Retrying
|
||||
# by delegating to another integration is exactly what this change
|
||||
# removes — surrender to the truth instead.
|
||||
_LOGGER.warning(
|
||||
"Intent %s had no serving transport to retry — falling back "
|
||||
"to truth",
|
||||
intent.command,
|
||||
)
|
||||
self._clear_intent()
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
self._intent_timer_unsub = async_call_later(
|
||||
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
|
||||
)
|
||||
@@ -399,13 +506,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@callback
|
||||
def _on_source_change(self, event: Event) -> None:
|
||||
"""React to state changes in androidtv or Jellyfin."""
|
||||
"""React to state changes in Jellyfin or the power switch."""
|
||||
entity_id = event.data.get("entity_id")
|
||||
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)
|
||||
entity_id == self._power_entity
|
||||
and new_state_obj
|
||||
and not self._is_power_on()
|
||||
and self._intent
|
||||
@@ -568,12 +675,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
if self._jf_active():
|
||||
return _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
# ADB media session also supports playback controls
|
||||
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
|
||||
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
else:
|
||||
# ADB media session also supports playback controls
|
||||
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):
|
||||
features = _BASE_FEATURES | _PLAYBACK_FEATURES
|
||||
else:
|
||||
features = _BASE_FEATURES
|
||||
|
||||
# t44/t47: a command that exists ONLY as a script still has to be
|
||||
# reachable, and the feature flag is what makes HA offer it. volume_set has
|
||||
# no native path at all; select_source appears when EITHER a script is
|
||||
# configured OR there is a source list to offer (t47 gave it a native path).
|
||||
if self._script_for("volume_set"):
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
if self._script_for("select_source") or self.source_list:
|
||||
features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
return features
|
||||
|
||||
# ── Metadata from Jellyfin ──
|
||||
|
||||
@@ -583,22 +702,96 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def app_id(self):
|
||||
"""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
|
||||
"""The raw foreground package from the manager's store (t41).
|
||||
|
||||
Both native transports stamp it: the protocol current-app push and the
|
||||
ADB mResumedActivity parse. None until one of them has reported.
|
||||
"""
|
||||
return self._manager.current_app if self._manager is not None else 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_directory(self) -> dict:
|
||||
"""Launchable apps: the user's map, else the curated default (P4-1).
|
||||
|
||||
ONE source for the browse tree, the source list and the reverse lookup, so
|
||||
the dropdown can never offer a name the launch path cannot resolve.
|
||||
|
||||
The map is hand-editable, so it is NORMALIZED here — once, so that every
|
||||
consumer sees the same thing (t51):
|
||||
|
||||
* a name is stripped, so display and match key are one string: an entry
|
||||
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
|
||||
"TiviMate", instead of being shown padded and missing every lookup;
|
||||
* an entry whose name — or whose package — is blank/whitespace-only is
|
||||
dropped, so no surface can offer a child with no package behind it or a
|
||||
name no launch can reach (the t48 invariant, extended to the tree);
|
||||
* two packages normalizing to the same name are BOTH KEPT — the browse
|
||||
tree reaches each by its own package id, so neither app is hidden — while
|
||||
the FIRST in map order wins the name: that is the single entry
|
||||
source_list offers and the package select_source resolves. Map order is
|
||||
the user's order, so the winner does not move between rebuilds. "The same
|
||||
name" is judged case-insensitively (t53), because the reverse lookup is:
|
||||
names differing only by case would otherwise give the dropdown a second
|
||||
row that launches the FIRST entry's app.
|
||||
|
||||
A well-formed map (what the editor and the curated default produce) passes
|
||||
through byte-identical: stripping only touches the edges, so a name like
|
||||
"My App" keeps its inner space.
|
||||
"""
|
||||
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||
normalized: dict[str, str] = {}
|
||||
for package, name in directory.items():
|
||||
key = str(package).strip()
|
||||
label = str(name).strip()
|
||||
if not key or not label:
|
||||
continue
|
||||
normalized[key] = label
|
||||
return normalized
|
||||
|
||||
@property
|
||||
def source_list(self) -> list[str]:
|
||||
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
|
||||
|
||||
Directory order (the user's map order, or the curated list), so rebuilding
|
||||
the page does not shuffle it. Blanks are already gone and names already
|
||||
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
|
||||
one accessor so the dropdown, the browse tree and the reverse lookup cannot
|
||||
disagree.
|
||||
|
||||
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
|
||||
first spelling the user typed. Exact duplicates were already collapsed, but
|
||||
``Dup``/``dup`` were not: the dropdown then showed two rows while the
|
||||
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
|
||||
of them to the first entry, so the second row launched the wrong app. One
|
||||
row per name means a row can only launch the app it shows. The shadowed
|
||||
package is not lost: the browse tree still lists it under its own id, and
|
||||
select_source accepts that id.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
offered: list[str] = []
|
||||
for name in self._app_directory.values():
|
||||
folded = name.casefold()
|
||||
if folded in seen:
|
||||
continue
|
||||
seen.add(folded)
|
||||
offered.append(name)
|
||||
return offered
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""The foreground app's friendly name — the same chain as app_name (t47).
|
||||
|
||||
``resolve_app_name``: the user's map, then the cached device-manifest label,
|
||||
then the raw package. Same answer as the Current App sensor, so a
|
||||
universal-media-player replacement cannot see two different truths.
|
||||
"""
|
||||
return self.app_name
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
"""P5-1 resolution order — explicit override first:
|
||||
@@ -606,25 +799,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
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)
|
||||
3. 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
|
||||
# t40: one implementation, shared with the Current App sensor
|
||||
# (``resolve_app_name``) — the two can no longer disagree about what
|
||||
# is on screen.
|
||||
package = self._manager.foreground_app if self._manager is not None else None
|
||||
return resolve_app_name(
|
||||
package,
|
||||
self._app_name_map,
|
||||
self._manager.app_label if self._manager is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
@@ -825,7 +1010,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""Extra state attributes — a PROPERTY, as HA requires (t40 blocker).
|
||||
|
||||
``Entity.__async_calculate_state`` merges them with
|
||||
``attr |= self.extra_state_attributes``; declaring this as a plain
|
||||
method made every state write raise
|
||||
``TypeError: 'method' object is not iterable``, so the entity could
|
||||
never render on a live install.
|
||||
"""
|
||||
# Now playing: prefer Jellyfin, fall back to ADB media session,
|
||||
# then the foreground tier (P3-3a).
|
||||
now_playing = self._get_now_playing()
|
||||
@@ -861,30 +1055,119 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
attrs["_intent"] = self._intent.to_debug_dict()
|
||||
return attrs
|
||||
|
||||
# ── Commands — ALL go to androidtv_remote ──
|
||||
# ── Commands — our own transports only (t41: the external entity is gone) ──
|
||||
|
||||
async def _cmd(self, service: str, **extra) -> None:
|
||||
data = {"entity_id": self._androidtv_entity, **extra}
|
||||
await self.hass.services.async_call("media_player", service, data)
|
||||
async def _power_key(self, *, send_when_on: bool) -> None:
|
||||
"""Send the protocol POWER key when the known state disagrees (t41).
|
||||
|
||||
Both native transports are ours now, so this no longer delegates to an
|
||||
androidtv_remote entity: the protocol POWER key is the path we own. An
|
||||
unknown state sends nothing (no superfluous toggle — the same rule the
|
||||
remote entity applies), and without the protocol transport there is
|
||||
nothing to transport the key over.
|
||||
"""
|
||||
manager = self._manager
|
||||
if manager is None or manager.is_on is not send_when_on:
|
||||
return
|
||||
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
|
||||
|
||||
def _script_for(self, command: str) -> str | None:
|
||||
"""The script configured to own ``command``, if any (t44)."""
|
||||
return command_scripts(self._entry.options).get(command)
|
||||
|
||||
async def _script_served(self, command: str, variables: dict | None = None) -> bool:
|
||||
"""True when a configured script handled ``command`` instead of the transports."""
|
||||
return await async_run_command_script(
|
||||
self.hass, self._entry.options, command, variables
|
||||
)
|
||||
|
||||
async def async_turn_on(self):
|
||||
await self._cmd("turn_on")
|
||||
"""Wake the Shield (only when it reports OFF) — or run the turn_on script."""
|
||||
if await self._script_served("turn_on"):
|
||||
return
|
||||
await self._power_key(send_when_on=False)
|
||||
|
||||
async def async_turn_off(self):
|
||||
await self._cmd("turn_off")
|
||||
"""Sleep the Shield (only when it reports ON) — or run the turn_off script."""
|
||||
if await self._script_served("turn_off"):
|
||||
return
|
||||
await self._power_key(send_when_on=True)
|
||||
|
||||
async def async_volume_up(self):
|
||||
if await self._script_served("volume_up"):
|
||||
return
|
||||
await self._remote_key_cmd("volume_up", "volume_up")
|
||||
|
||||
async def async_volume_down(self):
|
||||
if await self._script_served("volume_down"):
|
||||
return
|
||||
await self._remote_key_cmd("volume_down", "volume_down")
|
||||
|
||||
async def async_mute_volume(self, mute):
|
||||
"""Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL)."""
|
||||
"""Mute — via the configured script, else the protocol/ADB key (C-VOL)."""
|
||||
if await self._script_served("volume_mute", {SCRIPT_VAR_MUTED: mute}):
|
||||
return
|
||||
await self._remote_key_cmd("volume_mute", "mute")
|
||||
|
||||
async def async_set_volume_level(self, volume):
|
||||
"""Set absolute volume — reachable only with a script (t44).
|
||||
|
||||
There is no native path to replace: the protocol carries UP/DOWN/MUTE keys
|
||||
only (C-VOL) and ADB has no absolute volume either. Configured, this is the
|
||||
hook for a device whose volume is owned elsewhere (a speaker switch);
|
||||
unconfigured it stays the no-op it has always been.
|
||||
"""
|
||||
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
|
||||
|
||||
def _source_package(self, source) -> str | None:
|
||||
"""The package behind a source name, or a package id passed through (t47).
|
||||
|
||||
Both sides are normalized the same way: the query is stripped here and the
|
||||
names were stripped when the directory was built (t51), so a padded entry
|
||||
matches its trimmed spelling. Names are compared case-insensitively — which
|
||||
is why ``source_list`` collapses names that differ only by case (t53), so
|
||||
that the row it offers resolves back to the same app it displays. When two
|
||||
packages share a name the first in map order wins, the same one
|
||||
``source_list`` offers (see ``_app_directory``); the shadowed package stays
|
||||
reachable by passing its id.
|
||||
"""
|
||||
directory = self._app_directory
|
||||
wanted = str(source or "").strip()
|
||||
if not wanted:
|
||||
return None
|
||||
if wanted in directory:
|
||||
# an automation may hand us the package directly; that is what the
|
||||
# browse tree publishes as media_content_id
|
||||
return wanted
|
||||
folded = wanted.casefold()
|
||||
for package, name in directory.items():
|
||||
if str(name).casefold() == folded:
|
||||
return package
|
||||
return None
|
||||
|
||||
async def async_select_source(self, source):
|
||||
"""Select an app by its friendly name (t47) — script first, else launch it.
|
||||
|
||||
A configured ``select_source`` script keeps precedence (t44: selecting a
|
||||
source can be more than launching an app — an AVR input, a speaker switch).
|
||||
Without one the name is resolved back to its package through the SAME
|
||||
directory the source list came from and launched over the shared
|
||||
``play_media("app", …)`` path, so a universal-media-player replacement gets
|
||||
the native behaviour. An unknown name is an error, never a silent no-op.
|
||||
"""
|
||||
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
|
||||
return
|
||||
package = self._source_package(source)
|
||||
if package is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_source",
|
||||
translation_placeholders={"source": str(source)},
|
||||
)
|
||||
await self.async_play_media(MediaType.APP, package)
|
||||
|
||||
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
|
||||
"""Simple key command without intent bookkeeping: protocol → ADB → legacy."""
|
||||
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
|
||||
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
|
||||
if (
|
||||
protocol_key
|
||||
@@ -894,16 +1177,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
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).
|
||||
Order: protocol key push → ADB keyevent (t41: our own transports only —
|
||||
the transitional external-entity tier is gone). With neither transport
|
||||
up the command is a no-op, never a call into another integration.
|
||||
"""
|
||||
if intent_state:
|
||||
self._set_intent(ha_service, intent_state)
|
||||
@@ -936,9 +1218,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
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):
|
||||
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
|
||||
|
||||
@@ -977,7 +1256,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
removed a name media_player has not exported for a long time, which
|
||||
made every real browse raise.
|
||||
"""
|
||||
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
|
||||
app_map = self._app_directory
|
||||
children = [
|
||||
BrowseMedia(
|
||||
title=name,
|
||||
@@ -1073,16 +1352,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
|
||||
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
|
||||
# 1. Protocol digit key; 2. ADB keyevent 7..16 (t41: our own
|
||||
# transports only — an unserved digit is dropped, never handed
|
||||
# to another integration)
|
||||
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"},
|
||||
_LOGGER.warning(
|
||||
"channel digit %s not sent — no available transport", digit
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
if (
|
||||
|
||||
@@ -24,7 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PROTOCOL_REMOTE_KEYS,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
remote_command_script,
|
||||
)
|
||||
from .media_player import async_call_script, async_run_command_script, entity_available
|
||||
|
||||
_START_PREFIX = "START_LONG:"
|
||||
_END_PREFIX = "END_LONG:"
|
||||
@@ -93,17 +102,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Unavailable while the transport is active but unpaired/down.
|
||||
"""Available while ANY usable transport can serve a command (t40).
|
||||
|
||||
A passive manager (no protocol transport) keeps the entity available:
|
||||
nothing to transport is not a broken one.
|
||||
The same ladder as the media player (``entity_available``): protocol
|
||||
wired+available, ADB connected, fresh wake data or a passive manager all
|
||||
count as usable, and a passive manager stays available — nothing to
|
||||
transport is not a broken one.
|
||||
"""
|
||||
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
|
||||
return entity_available(self._manager_of())
|
||||
|
||||
@property
|
||||
def activity(self) -> str | None:
|
||||
@@ -133,7 +139,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
self._manager_unregistered = None
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Wake the device with the POWER key when it reports off."""
|
||||
"""Wake the device — via the configured script, else the POWER key."""
|
||||
# t44: `turn_on` is one configuration, and the remote entity is the other
|
||||
# route to it; a script that owns the power sequence owns it here too.
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
@@ -143,7 +153,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
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."""
|
||||
"""Sleep the device — via the configured script, else the POWER key."""
|
||||
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
|
||||
return
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
@@ -158,7 +170,29 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
|
||||
delay_secs: float = 0.0,
|
||||
hold_secs: float = 0.0,
|
||||
) -> None:
|
||||
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG)."""
|
||||
"""Send protocol keys — or hand the whole command to the configured script.
|
||||
|
||||
t47: with a remote command script configured, it replaces the key path
|
||||
entirely and receives the service call's own parameters, named as they are
|
||||
on the service: ``command`` (a list of key commands), ``num_repeats``,
|
||||
``delay_secs`` and ``hold_secs``. Unset, nothing changes: repeats, long
|
||||
presses and delays go out as protocol keys exactly as before.
|
||||
"""
|
||||
script = remote_command_script(self._entry.options)
|
||||
if script:
|
||||
await async_call_script(
|
||||
self.hass,
|
||||
script,
|
||||
"remote.send_command",
|
||||
{
|
||||
SCRIPT_VAR_COMMAND: command,
|
||||
SCRIPT_VAR_NUM_REPEATS: num_repeats,
|
||||
SCRIPT_VAR_DELAY_SECS: delay_secs,
|
||||
SCRIPT_VAR_HOLD_SECS: hold_secs,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
manager = self._manager_of()
|
||||
if not isinstance(manager, ShieldConnectionManager):
|
||||
return
|
||||
|
||||
@@ -12,10 +12,11 @@ from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .connection import ShieldConnectionManager
|
||||
from .const import (
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_APP_NAME_MAP,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .media_player import resolve_app_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,9 +66,9 @@ class NvidiaShieldSensorBase(SensorEntity):
|
||||
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
"""Current running app on the Shield.
|
||||
|
||||
Primary source: the androidtv entity's own state. When that entity is
|
||||
unavailable, falls back to the ShieldConnectionManager's StateStore
|
||||
(most recent visible ADB recent-task app).
|
||||
Source: the ShieldConnectionManager's StateStore — the protocol current-app
|
||||
push, or the most recent visible ADB recent-task app when that has not
|
||||
reported (t41: our own transports only).
|
||||
"""
|
||||
|
||||
_attr_translation_key = "current_app"
|
||||
@@ -75,7 +76,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, entry)
|
||||
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:
|
||||
@@ -83,56 +83,59 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
|
||||
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 state:
|
||||
return state.attributes.get("app_name") or state.attributes.get("app_id")
|
||||
def _package(self) -> str | None:
|
||||
"""The raw foreground package from the manager's store (t41).
|
||||
|
||||
# androidtv entity unavailable — StateStore priority chain (P3-3c):
|
||||
# protocol current_app push → ADB mResumedActivity → ADB recents
|
||||
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")
|
||||
if manager is None:
|
||||
return None
|
||||
foreground = manager.foreground_app
|
||||
if foreground:
|
||||
return foreground
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return running[0].get("package")
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
state = self.hass.states.get(self._androidtv_entity)
|
||||
if state:
|
||||
return {
|
||||
"app_id": state.attributes.get("app_id"),
|
||||
"app_name": state.attributes.get("app_name"),
|
||||
}
|
||||
def native_value(self) -> str | None:
|
||||
"""The resolved friendly name (t40), not the raw package id.
|
||||
|
||||
Exactly the ``media_player.app_name`` chain via the shared
|
||||
``resolve_app_name``: apps map → cached manifest label → the package
|
||||
text itself. A bare or unknown package shows its package text rather
|
||||
than None, like the media player's app_name.
|
||||
"""
|
||||
manager = self._manager()
|
||||
if manager is not None:
|
||||
running = manager.running_apps
|
||||
if running:
|
||||
return {"app_id": running[0].get("package")}
|
||||
return {}
|
||||
return resolve_app_name(
|
||||
self._package(),
|
||||
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
|
||||
manager.app_label if manager is not None else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""The raw package stays visible here (t40) next to the shown name."""
|
||||
package = self._package()
|
||||
attrs: dict = {}
|
||||
if package:
|
||||
attrs["app_id"] = package
|
||||
name = self.native_value
|
||||
if name:
|
||||
attrs["app_name"] = name
|
||||
return attrs
|
||||
|
||||
|
||||
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
|
||||
|
||||
@@ -3,45 +3,41 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"androidtv_entity": "Android TV Remote entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"protocol_host": "Protocol host (IP address)"
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
|
||||
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"data": {
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"adb_port": "The standard ADB port is 5555.",
|
||||
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||
}
|
||||
@@ -51,12 +47,12 @@
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"protocol_host": "Protocol host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"skip_pairing": "Continue without protocol pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
@@ -67,11 +63,12 @@
|
||||
},
|
||||
"error": {
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
@@ -101,14 +98,23 @@
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
@@ -117,13 +123,15 @@
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 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, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +164,14 @@
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -3,45 +3,41 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "NVIDIA Shield",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
|
||||
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"androidtv_entity": "Android TV Remote entity",
|
||||
"jellyfin_entity": "Jellyfin media player",
|
||||
"receiver_entity": "Receiver",
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"protocol_host": "Protocol host (IP address)"
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol"
|
||||
},
|
||||
"data_description": {
|
||||
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
|
||||
"receiver_entity": "The receiver or soundbar this Shield plays through.",
|
||||
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
|
||||
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
|
||||
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
|
||||
}
|
||||
},
|
||||
"adb_pair": {
|
||||
"title": "Allow ADB access",
|
||||
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
|
||||
"data": {
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"skip_pairing": "Continue without ADB pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
|
||||
"adb_port": "The standard ADB port is 5555.",
|
||||
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
|
||||
}
|
||||
@@ -51,12 +47,12 @@
|
||||
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
|
||||
"data": {
|
||||
"pairing_code": "Pairing code shown on the Shield",
|
||||
"protocol_host": "Protocol host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"skip_pairing": "Continue without protocol pairing"
|
||||
},
|
||||
"data_description": {
|
||||
"pairing_code": "The code the Shield displays while this step is open.",
|
||||
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
|
||||
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
|
||||
}
|
||||
},
|
||||
@@ -67,11 +63,12 @@
|
||||
},
|
||||
"error": {
|
||||
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
|
||||
"adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
|
||||
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
|
||||
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
|
||||
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
|
||||
"protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
|
||||
"no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
|
||||
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
|
||||
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
|
||||
},
|
||||
@@ -101,14 +98,23 @@
|
||||
"receiver_input": "Receiver input",
|
||||
"power_entity": "Power switch",
|
||||
"adb_enabled": "Read state over ADB",
|
||||
"protocol_enabled": "Add the Nvidia Remote Protocol",
|
||||
"adb_store_app_info": "Show title and artist",
|
||||
"adb_host": "ADB host (IP address)",
|
||||
"host": "The Shield's IP address",
|
||||
"adb_port": "ADB port",
|
||||
"app_name_map": "App names and icons",
|
||||
"adb_command_enabled": "Enable the adb_command service",
|
||||
"channel_digit_delay": "Channel digit delay (seconds)",
|
||||
"channel_send_enter": "Send ENTER after channel digits",
|
||||
"logcat_events": "Instant app detection (logcat stream)"
|
||||
"script_turn_on": "Turn on script",
|
||||
"script_turn_off": "Turn off script",
|
||||
"script_volume_up": "Volume up script",
|
||||
"script_volume_down": "Volume down script",
|
||||
"script_volume_set": "Volume set script",
|
||||
"script_volume_mute": "Mute script",
|
||||
"script_select_source": "Select source script",
|
||||
"logcat_events": "Instant app detection (logcat stream)",
|
||||
"remote_command_script": "Remote command script"
|
||||
},
|
||||
"data_description": {
|
||||
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
|
||||
@@ -117,13 +123,15 @@
|
||||
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
|
||||
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
|
||||
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
|
||||
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
|
||||
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
|
||||
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
|
||||
"adb_port": "The ADB port. The standard value is 5555.",
|
||||
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
|
||||
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 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, the tested value).",
|
||||
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
|
||||
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
|
||||
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +164,14 @@
|
||||
"adb_unavailable": {
|
||||
"title": "ADB unavailable",
|
||||
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
|
||||
},
|
||||
"unknown_source": {
|
||||
"title": "Unknown source",
|
||||
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
|
||||
},
|
||||
"command_script_failed": {
|
||||
"title": "Command script failed",
|
||||
"message": "The script configured for {action} ({script}) failed: {error}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Resolve the version this push should release (t42).
|
||||
#
|
||||
# Why a script: this decision is what makes a release visible to HACS, and every
|
||||
# wrong answer is silent — the tag and the release still exist, only the manifest
|
||||
# never moves. So the logic lives here, where tests/test_release_versioning.py
|
||||
# drives every branch locally, instead of inline in a workflow nobody can run.
|
||||
#
|
||||
# Resolution (M = manifest version, D = today in UTC, tags = existing v-tags):
|
||||
# 1. M is tagged AND that tag is the commit being released
|
||||
# -> RELEASE=no (SOURCE=released): a re-run, this commit is already out
|
||||
# 2. M is not tagged -> VERSION=M (SOURCE=manifest)
|
||||
# an unreleased manifest version wins: bump it to force a release
|
||||
# 3. D is not tagged -> VERSION=D (SOURCE=date)
|
||||
# the one automatic release per UTC day
|
||||
# 4. otherwise -> VERSION=D.N (SOURCE=same-day)
|
||||
# N is the LOWEST untagged N >= 2, so further releases in the same UTC day
|
||||
# become 2026.10.11.2, 2026.10.11.3, … instead of silently doing nothing
|
||||
#
|
||||
# The workflow writes the resolved VERSION back into the manifest whenever it
|
||||
# differs, then tags THAT commit, so the released artifact and its tag always
|
||||
# agree. The defect this fixes: v2026.10.09 shipped while master's manifest still
|
||||
# said 2026.10.08, leaving HACS comparing against a version that never moved.
|
||||
#
|
||||
# Usage: scripts/determine-release-version.sh [--manifest PATH] [--today YYYY.MM.DD]
|
||||
# [--tags-from FILE] [--head SHA]
|
||||
#
|
||||
# --manifest manifest to read (default: custom_components/nvidia_shield/manifest.json)
|
||||
# --today override the UTC date — for tests and dry runs only
|
||||
# --tags-from read "TAG [SHA]" lines instead of asking git — for tests and dry runs
|
||||
# --head the commit being released (default: git rev-parse HEAD)
|
||||
#
|
||||
# Stdout, and $GITHUB_OUTPUT as well when it is set (which is what the workflow
|
||||
# consumes): VERSION, TAG, SOURCE, BUMP, RELEASE. Commentary goes to stderr.
|
||||
# RELEASE=no is the explicit no-op signal: nothing may be tagged or published.
|
||||
# Exit status is 1 only for a usage or manifest error, so "no release" is not a
|
||||
# failed job.
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="custom_components/nvidia_shield/manifest.json"
|
||||
TAGS_FROM=""
|
||||
HEAD_SHA=""
|
||||
TODAY=""
|
||||
|
||||
usage() {
|
||||
sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--manifest) MANIFEST="${2:?--manifest needs a path}"; shift 2 ;;
|
||||
--today) TODAY="${2:?--today needs YYYY.MM.DD}"; shift 2 ;;
|
||||
--tags-from) TAGS_FROM="${2:?--tags-from needs a path}"; shift 2 ;;
|
||||
--head) HEAD_SHA="${2:?--head needs a sha}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
note() { printf '%s\n' "$*" >&2; }
|
||||
|
||||
[[ -f "$MANIFEST" ]] || { note "manifest not found: $MANIFEST"; exit 1; }
|
||||
command -v jq >/dev/null 2>&1 || { note "jq is required to read the manifest"; exit 1; }
|
||||
|
||||
MANIFEST_VERSION="$(jq -r '.version // empty' "$MANIFEST")"
|
||||
[[ -n "$MANIFEST_VERSION" ]] || { note "no .version in $MANIFEST"; exit 1; }
|
||||
# Tolerate a hand-written leading "v" (the tag adds its own); anything else that
|
||||
# is not YYYY.MM.DD[.N] is a mistake worth failing for.
|
||||
MANIFEST_VERSION="${MANIFEST_VERSION#v}"
|
||||
[[ "$MANIFEST_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || {
|
||||
note "manifest version '$MANIFEST_VERSION' is not YYYY.MM.DD[.N]"; exit 1; }
|
||||
|
||||
if [[ -n "$TODAY" ]]; then
|
||||
[[ "$TODAY" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || { note "--today must be YYYY.MM.DD"; exit 1; }
|
||||
DATE_VERSION="$TODAY"
|
||||
else
|
||||
# UTC, always: a runner's local timezone must never decide a version number.
|
||||
DATE_VERSION="$(date -u +%Y.%m.%d)"
|
||||
fi
|
||||
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
[[ -f "$TAGS_FROM" ]] || { note "tag list not found: $TAGS_FROM"; exit 1; }
|
||||
else
|
||||
command -v git >/dev/null 2>&1 || { note "git is required to list tags"; exit 1; }
|
||||
fi
|
||||
|
||||
tag_exists() {
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
awk -v wanted="$1" '$1 == wanted { found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||
else
|
||||
git rev-parse -q --verify "refs/tags/$1" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
tag_sha() {
|
||||
if [[ -n "$TAGS_FROM" ]]; then
|
||||
awk -v wanted="$1" '$1 == wanted && NF > 1 { print $2; found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
|
||||
else
|
||||
git rev-parse -q --verify "refs/tags/$1^{commit}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$HEAD_SHA" ]] && [[ -z "$TAGS_FROM" ]]; then
|
||||
HEAD_SHA="$(git rev-parse -q --verify HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
VERSION=""
|
||||
SOURCE=""
|
||||
RELEASE="yes"
|
||||
|
||||
if tag_exists "v${MANIFEST_VERSION}" && [[ -n "$HEAD_SHA" ]] \
|
||||
&& [[ "$(tag_sha "v${MANIFEST_VERSION}" 2>/dev/null || true)" == "$HEAD_SHA" ]]; then
|
||||
VERSION="$MANIFEST_VERSION"; SOURCE="released"; RELEASE="no"
|
||||
note "v${VERSION} is already tagged on this commit — nothing to release"
|
||||
elif ! tag_exists "v${MANIFEST_VERSION}"; then
|
||||
VERSION="$MANIFEST_VERSION"; SOURCE="manifest"
|
||||
elif ! tag_exists "v${DATE_VERSION}"; then
|
||||
VERSION="$DATE_VERSION"; SOURCE="date"
|
||||
else
|
||||
N=2
|
||||
while tag_exists "v${DATE_VERSION}.${N}"; do N=$((N + 1)); done
|
||||
VERSION="${DATE_VERSION}.${N}"; SOURCE="same-day"
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "yes" ]] && tag_exists "v${VERSION}"; then
|
||||
# Backstop, not a normal path: the resolution above only ever picks an
|
||||
# untagged version, so reaching this means the tag set changed underneath us
|
||||
# (a race with a parallel run — the concurrency group is the primary guard).
|
||||
# Never tag or publish something that already exists.
|
||||
RELEASE="no"; SOURCE="tagged"
|
||||
note "v${VERSION} already exists — nothing to release"
|
||||
fi
|
||||
|
||||
BUMP="no"
|
||||
if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then
|
||||
BUMP="yes"
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "yes" ]]; then
|
||||
note "releasing v${VERSION} (${SOURCE}); manifest says ${MANIFEST_VERSION}, bump=${BUMP}"
|
||||
else
|
||||
note "no release (${SOURCE})"
|
||||
fi
|
||||
|
||||
emit() {
|
||||
printf '%s=%s\n' "$1" "$2"
|
||||
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
||||
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
}
|
||||
|
||||
emit VERSION "$VERSION"
|
||||
emit TAG "v${VERSION}"
|
||||
emit SOURCE "$SOURCE"
|
||||
emit BUMP "$BUMP"
|
||||
emit RELEASE "$RELEASE"
|
||||
@@ -50,3 +50,4 @@ _load_module(
|
||||
# Stdlib-only integration modules.
|
||||
for _mod in ("const", "adb_session", "connection"):
|
||||
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
|
||||
|
||||
+111
-4
@@ -42,6 +42,37 @@ ENTITIES_STUB_NAMES = (
|
||||
)
|
||||
|
||||
|
||||
class EntitySelectorStub:
|
||||
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
|
||||
|
||||
Real HA answers a cleared entity field with "Schema validation failed at …"
|
||||
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
|
||||
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
|
||||
values to prove it.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def __call__(self, data):
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
raise ValueError("Entity is not a valid entity id")
|
||||
return data
|
||||
|
||||
|
||||
def install_selector_faces(selector_module) -> None:
|
||||
"""Populate a ``homeassistant.helpers.selector`` stub module.
|
||||
|
||||
One definition for every installer (entity_stublib and the four per-file
|
||||
installers in the flow tests), so the stubs cannot drift apart.
|
||||
"""
|
||||
selector_module.EntitySelector = EntitySelectorStub
|
||||
selector_module.EntitySelectorConfig = lambda *args, **kwargs: ""
|
||||
selector_module.SelectSelector = lambda *args, **kwargs: None
|
||||
selector_module.SelectSelectorConfig = lambda **kwargs: ""
|
||||
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
|
||||
|
||||
class MediaPlayerState(enum.StrEnum):
|
||||
OFF = "off"
|
||||
ON = "on"
|
||||
@@ -63,6 +94,7 @@ class MediaPlayerEntityFeature(enum.IntFlag):
|
||||
PLAY_MEDIA = 256
|
||||
NEXT_TRACK = 512
|
||||
PREVIOUS_TRACK = 1024
|
||||
SELECT_SOURCE = 2048
|
||||
|
||||
|
||||
class MediaClass:
|
||||
@@ -172,9 +204,30 @@ class RemoteEntity:
|
||||
return None
|
||||
|
||||
|
||||
#: Every face install() registers. The guard below checks the WHOLE set, not one
|
||||
#: sentinel: test files scope their stubs per file (snapshot + pop at teardown, the
|
||||
#: t7 discipline), so one file can leave `homeassistant.components.media_player`
|
||||
#: behind while another name it popped — e.g. `voluptuous` — is gone. A sentinel
|
||||
#: guard then made install() a no-op in the next file, and whichever module needed
|
||||
#: the missing face failed with ModuleNotFoundError depending on file order (t44).
|
||||
#: The faces install() provides, derived from the list every test file scopes on.
|
||||
#: The ``nsh.*`` entries there are the integration modules the harness loads, not
|
||||
#: stub faces, so they are filtered out here.
|
||||
_STUB_FACES = tuple(
|
||||
name for name in ENTITIES_STUB_NAMES if not name.startswith("nsh.")
|
||||
)
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Register the stub modules (idempotent)."""
|
||||
if "homeassistant.components.media_player" in sys.modules:
|
||||
"""Register the stub modules (idempotent, and self-healing).
|
||||
|
||||
Callers must be able to rely on this regardless of what a previous test file
|
||||
popped: a missing face rebuilds the whole set, so any subset or file order
|
||||
works. A complete set is left alone — a file that installed richer faces of
|
||||
its own (tests/test_config_flow_ux.py's voluptuous mirror, the discovery
|
||||
stub) keeps them.
|
||||
"""
|
||||
if all(name in sys.modules for name in _STUB_FACES):
|
||||
return
|
||||
|
||||
vol = types.ModuleType("voluptuous")
|
||||
@@ -307,12 +360,19 @@ def install() -> None:
|
||||
|
||||
exceptions = types.ModuleType("homeassistant.exceptions")
|
||||
|
||||
class ServiceValidationErrorStubError(Exception):
|
||||
class HomeAssistantErrorStubError(Exception):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args or (kwargs.get("translation_key"),))
|
||||
self.translation_domain = kwargs.get("translation_domain")
|
||||
self.translation_key = kwargs.get("translation_key")
|
||||
self.translation_placeholders = kwargs.get("translation_placeholders")
|
||||
|
||||
class ServiceValidationErrorStubError(HomeAssistantErrorStubError):
|
||||
"""Real HA: ServiceValidationError derives from HomeAssistantError (t44)."""
|
||||
|
||||
# t44: media_player raises HomeAssistantError when a configured command script
|
||||
# fails, so both names have to exist — and with the real inheritance.
|
||||
exceptions.HomeAssistantError = HomeAssistantErrorStubError
|
||||
exceptions.ServiceValidationError = ServiceValidationErrorStubError
|
||||
sys.modules["homeassistant.exceptions"] = exceptions
|
||||
|
||||
@@ -400,7 +460,7 @@ def install() -> None:
|
||||
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
|
||||
helpers.event = event
|
||||
helpers.selector = types.SimpleNamespace(
|
||||
EntitySelector=lambda *a, **k: None,
|
||||
EntitySelector=EntitySelectorStub,
|
||||
EntitySelectorConfig=lambda *a, **k: "",
|
||||
SelectSelector=lambda *a, **k: None,
|
||||
SelectSelectorConfig=lambda **k: "",
|
||||
@@ -455,9 +515,51 @@ def install() -> None:
|
||||
sys.modules["homeassistant.helpers.event"] = event
|
||||
sys.modules["homeassistant.helpers.entity"] = entity
|
||||
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
|
||||
|
||||
# t44: config_flow imports `selector` from this package, and the per-file stub
|
||||
# installers register it as its own face. Installing it here too keeps
|
||||
# `_STUB_FACES` honest: install() then really does provide every face a test
|
||||
# file may have popped, which is what its guard checks.
|
||||
selector = types.ModuleType("homeassistant.helpers.selector")
|
||||
install_selector_faces(selector)
|
||||
selector.SelectSelector = lambda *args, **kwargs: None
|
||||
selector.SelectSelectorConfig = lambda **kwargs: ""
|
||||
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
|
||||
helpers.selector = selector
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
sys.modules["homeassistant.helpers"] = helpers
|
||||
|
||||
|
||||
#: Namespaces the stub harness owns. Anything under these prefixes that a test
|
||||
#: file creates is harness state, so a file's teardown must remove it: a module
|
||||
#: left behind keeps the stub objects it captured (an exception class, the redact
|
||||
#: helper the diagnostics module holds) after the next file rebuilt them, which is
|
||||
#: how a file-order-dependent failure looked (t44).
|
||||
_TRACKED_PREFIXES = ("homeassistant", "voluptuous", "nsh")
|
||||
|
||||
|
||||
def _tracked(name: str) -> bool:
|
||||
return name.split(".", 1)[0] in _TRACKED_PREFIXES
|
||||
|
||||
|
||||
def snapshot_modules() -> dict:
|
||||
"""Every stub/module name live right now — for a leak-free teardown."""
|
||||
return {name: module for name, module in sys.modules.items() if _tracked(name)}
|
||||
|
||||
|
||||
def restore_modules(snapshot: dict) -> None:
|
||||
"""Undo everything the current test file did to those namespaces.
|
||||
|
||||
Two halves, and the first is what a hand-written list of names kept getting
|
||||
wrong: drop every tracked name the file created (so the next file loads it
|
||||
fresh, against ITS stubs), then put back every object it replaced.
|
||||
"""
|
||||
for name in [name for name in sys.modules if _tracked(name) and name not in snapshot]:
|
||||
del sys.modules[name]
|
||||
sys.modules.update(snapshot)
|
||||
|
||||
|
||||
def load_entity_modules() -> dict:
|
||||
"""Load the integration's HA-free-ish entity modules under nsh."""
|
||||
install()
|
||||
@@ -471,6 +573,11 @@ def load_entity_modules() -> dict:
|
||||
"media_player",
|
||||
"remote",
|
||||
"diagnostics",
|
||||
# t41: button.py was the file nobody loaded — a deleted helper it still
|
||||
# imported shipped silently. It is a platform like the others, so it
|
||||
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
|
||||
# is what keeps this list from having to be complete.
|
||||
"button",
|
||||
"init",
|
||||
): # "init" maps the integration __init__.py (t29 G8 async_setup)
|
||||
if name == "init" and "nsh.init" in sys.modules:
|
||||
|
||||
+420
-3
@@ -30,7 +30,24 @@ dependency against an INSTALLED homeassistant:
|
||||
must exist in the real signature — the ``options_updates`` class of bug;
|
||||
3. every ``self.<method>()`` call inside a class that inherits from an HA class
|
||||
is compared against the real definition: a coroutine must be awaited, and an
|
||||
awaited call must actually be a coroutine.
|
||||
awaited call must actually be a coroutine;
|
||||
4. every class inheriting from an HA entity base has its HA-READ attributes
|
||||
(``extra_state_attributes``, ``available``, …) declared as properties — HA
|
||||
merges them with ``attr |= self.extra_state_attributes``, so a plain method
|
||||
raises ``TypeError: 'method' object is not iterable`` on every state write and
|
||||
the entity can never render (t40 live install: ``media_player.py:828``). The
|
||||
stub suites call the method happily; this pass is pure AST and, unlike 1-3,
|
||||
needs no homeassistant at all;
|
||||
5. the package's OWN relative imports (t41): ``from .helpers import X`` must
|
||||
resolve to a file of this package that defines ``X`` (a submodule counts).
|
||||
``button.py`` imported a helper that a refactor had deleted, and because no
|
||||
harness loaded that file the ImportError waited for a user's log — pass 1
|
||||
cannot see it (it only judges ``homeassistant.*``). Also pure AST, with its
|
||||
own entry point ``scan_package_imports()`` for the fast suite.
|
||||
|
||||
Both AST passes run before the homeassistant availability check, and the file
|
||||
walk is recursive (``_iter_python_files``), so a platform or helper one
|
||||
directory down cannot hide from either of them.
|
||||
|
||||
Known limits (deliberate, and why the sweep still catches the real classes):
|
||||
``**kwargs`` splats cannot be checked statically, calls on objects other than
|
||||
@@ -99,8 +116,11 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
|
||||
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or
|
||||
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
|
||||
FLOORS: dict[str, int] = {
|
||||
"modules": 10,
|
||||
# t41: the sweep is recursive, so the floor sits above the top-level-only
|
||||
# count (13) — a regression to the shallow glob trips it.
|
||||
"modules": 20,
|
||||
"imports": 40,
|
||||
"relative_imports": 100,
|
||||
"call_args": 20,
|
||||
"awaited_calls": 5,
|
||||
}
|
||||
@@ -136,6 +156,7 @@ class Report:
|
||||
findings: list[Finding] = field(default_factory=list)
|
||||
modules: int = 0
|
||||
imports: int = 0
|
||||
relative_imports: int = 0
|
||||
call_args: int = 0
|
||||
awaited_calls: int = 0
|
||||
|
||||
@@ -157,6 +178,7 @@ class Report:
|
||||
return (
|
||||
"no Home-Assistant API drift "
|
||||
f"({self.modules} modules, {self.imports} imports, "
|
||||
f"{self.relative_imports} relative imports, "
|
||||
f"{self.call_args} call arguments, "
|
||||
f"{self.awaited_calls} awaited HA calls)"
|
||||
)
|
||||
@@ -166,6 +188,19 @@ class Report:
|
||||
# --------------------------------------------------------------------- imports
|
||||
|
||||
|
||||
def _iter_python_files(root: Path) -> list[Path]:
|
||||
"""Every Python file under a target, recursively (t41).
|
||||
|
||||
The sweep used to glob one level, so a platform or helper one directory down
|
||||
was invisible to both passes. Exhaustive-by-construction beats a list.
|
||||
"""
|
||||
if not root.is_dir():
|
||||
return [root]
|
||||
return sorted(
|
||||
path for path in root.rglob("*.py") if "__pycache__" not in path.parts
|
||||
)
|
||||
|
||||
|
||||
def _check_import(module_name: str, attr: str | None) -> str | None:
|
||||
"""Return a reason string when the import target is missing, else None."""
|
||||
try:
|
||||
@@ -223,6 +258,206 @@ def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- the package's own imports
|
||||
#
|
||||
# Pure AST, no Home Assistant required — the same contract as the entity pass, so
|
||||
# the fast suite can prove it even in the androidtvremote2-less variant.
|
||||
|
||||
|
||||
def _package_root(path: Path) -> Path:
|
||||
"""The integration package a file belongs to.
|
||||
|
||||
The topmost ancestor directory that still has an ``__init__.py``: that is
|
||||
``custom_components/nvidia_shield`` for both its top-level modules and the
|
||||
vendored ``ha_adb_common`` subpackage, and it holds for a copied tree too
|
||||
(tests scan temp copies).
|
||||
"""
|
||||
root = path.parent
|
||||
while (root.parent / "__init__.py").exists():
|
||||
root = root.parent
|
||||
return root
|
||||
|
||||
|
||||
class _ModuleLevelNames(ast.NodeVisitor):
|
||||
"""Names a module binds at import time.
|
||||
|
||||
Functions, classes, assignments and imports — including the ones inside
|
||||
``if``/``try`` guards (the optional androidtvremote2 import lives in one).
|
||||
Function and class BODIES are skipped: a name bound at call time is not
|
||||
importable, which is exactly the distinction this pass needs.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.names: set[str] = set()
|
||||
|
||||
def visit_FunctionDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_AsyncFunctionDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_ClassDef(self, node: ast.AST) -> None:
|
||||
self.names.add(node.name)
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
for alias in node.names:
|
||||
self.names.add(alias.asname or alias.name.split(".")[0])
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
for alias in node.names:
|
||||
if alias.name != "*":
|
||||
self.names.add(alias.asname or alias.name)
|
||||
|
||||
def _bind(self, target: ast.AST) -> None:
|
||||
if isinstance(target, ast.Name):
|
||||
self.names.add(target.id)
|
||||
elif isinstance(target, (ast.Tuple, ast.List)):
|
||||
for element in target.elts:
|
||||
self._bind(element)
|
||||
elif isinstance(target, ast.Starred):
|
||||
self._bind(target.value)
|
||||
|
||||
def visit_Assign(self, node: ast.Assign) -> None:
|
||||
for target in node.targets:
|
||||
self._bind(target)
|
||||
|
||||
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
||||
self._bind(node.target)
|
||||
|
||||
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
||||
self._bind(node.target)
|
||||
|
||||
def visit_For(self, node: ast.For) -> None:
|
||||
self._bind(node.target)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
|
||||
self._bind(node.target)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_With(self, node: ast.With) -> None:
|
||||
for item in node.items:
|
||||
if item.optional_vars is not None:
|
||||
self._bind(item.optional_vars)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
|
||||
for item in node.items:
|
||||
if item.optional_vars is not None:
|
||||
self._bind(item.optional_vars)
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def _module_level_names(tree: ast.AST) -> set[str]:
|
||||
visitor = _ModuleLevelNames()
|
||||
visitor.visit(tree)
|
||||
return visitor.names
|
||||
|
||||
|
||||
def _resolve_relative_import(path: Path, node: ast.ImportFrom, root: Path) -> Path | None:
|
||||
"""The file ``from ... import`` points at, or None when it leaves the package."""
|
||||
base = path.parent
|
||||
for _ in range(node.level - 1):
|
||||
base = base.parent
|
||||
if not base.is_relative_to(root):
|
||||
return None
|
||||
if not node.module:
|
||||
return base / "__init__.py"
|
||||
target = base.joinpath(*node.module.split("."))
|
||||
if target.is_dir():
|
||||
return target / "__init__.py"
|
||||
return target.with_suffix(".py")
|
||||
|
||||
|
||||
def _scan_relative_imports(path: Path, tree: ast.AST, report: Report) -> None:
|
||||
"""t41: resolve the integration's OWN imports, not only ``homeassistant.*``.
|
||||
|
||||
``_scan_imports`` proves the HA surface; it says nothing about
|
||||
``from .helpers import discover_adb_host`` once that helper is deleted — the
|
||||
exact defect that shipped from ``button.py`` because no harness loaded the
|
||||
file. This pass is pure AST against the tree, so it needs no importable
|
||||
Home Assistant and cannot be masked by a missing stub: the target file must
|
||||
exist and must define every name imported from it (a submodule counts too).
|
||||
"""
|
||||
root = _package_root(path)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ImportFrom) or not node.level:
|
||||
continue # absolute imports are _scan_imports' business
|
||||
spelling = f"{'.' * node.level}{node.module or ''}"
|
||||
target = _resolve_relative_import(path, node, root)
|
||||
if target is None:
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import ... escapes the package — the "
|
||||
"integration may only import from itself",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not target.exists():
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import ...: no module at "
|
||||
f"{target.relative_to(root)}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
defined = _module_level_names(
|
||||
ast.parse(target.read_text(encoding="utf-8"), filename=str(target))
|
||||
)
|
||||
for alias in node.names:
|
||||
if alias.name == "*":
|
||||
continue
|
||||
report.relative_imports += 1
|
||||
if alias.name in defined:
|
||||
continue
|
||||
# `from .package import submodule` is valid without the name being
|
||||
# bound in __init__ — the import system resolves the submodule.
|
||||
sibling = target.parent / f"{alias.name}.py"
|
||||
sibling_package = target.parent / alias.name / "__init__.py"
|
||||
if sibling.exists() or sibling_package.exists():
|
||||
continue
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(path),
|
||||
node.lineno,
|
||||
"broken-relative-import",
|
||||
f"from {spelling} import {alias.name}: not defined in "
|
||||
f"{target.relative_to(root)}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _scan_package_imports(paths, report: Report) -> None:
|
||||
"""Walk the tree and resolve every one of the package's own imports."""
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
continue # scan() reports bad-path for the same target
|
||||
for file in _iter_python_files(root):
|
||||
try:
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
_scan_relative_imports(file, tree, report)
|
||||
|
||||
|
||||
def scan_package_imports(paths=None) -> list[Finding]:
|
||||
"""The relative-import pass on its own (t41) — the fast suite's entry point."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_scan_package_imports(paths, report)
|
||||
return report.findings
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- keywords
|
||||
|
||||
|
||||
@@ -439,6 +674,183 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
|
||||
|
||||
# ----------------------------------------------------------- entity contracts
|
||||
|
||||
#: Attributes Home Assistant reads as ATTRIBUTES — it never calls them. A plain
|
||||
#: method with one of these names is invisible to the stub suites (permissive
|
||||
#: stubs, and the harness replaces ``async_write_ha_state``) and fatal under real
|
||||
#: HA: ``Entity.__async_calculate_state`` merges ``extra_state_attributes`` with
|
||||
#: ``attr |= self.extra_state_attributes``, which raises
|
||||
#: ``TypeError: 'method' object is not iterable`` on every state write. That was
|
||||
#: the t40 live-install blocker (``media_player.py:828``).
|
||||
ENTITY_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"activity",
|
||||
"activity_list",
|
||||
"app_id",
|
||||
"app_name",
|
||||
"assumed_state",
|
||||
"attribution",
|
||||
"available",
|
||||
"capability_attributes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_picture",
|
||||
"extra_state_attributes",
|
||||
"icon",
|
||||
"is_volume_muted",
|
||||
"last_reset",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_id",
|
||||
"media_content_type",
|
||||
"media_duration",
|
||||
"media_image_url",
|
||||
"media_position",
|
||||
"media_title",
|
||||
"name",
|
||||
"native_unit_of_measurement",
|
||||
"native_value",
|
||||
"options",
|
||||
"should_poll",
|
||||
"sound_mode",
|
||||
"source",
|
||||
"source_list",
|
||||
"state",
|
||||
"state_attributes",
|
||||
"supported_features",
|
||||
"translation_key",
|
||||
"unique_id",
|
||||
"unit_of_measurement",
|
||||
"volume_level",
|
||||
}
|
||||
)
|
||||
|
||||
#: Entity base classes Home Assistant ships. A class counts as an entity when its
|
||||
#: transitive bases include one of these, so our own intermediate bases
|
||||
#: (``NvidiaShieldSensorBase``) are covered as well.
|
||||
HA_ENTITY_BASES = frozenset(
|
||||
{
|
||||
"AlarmControlPanelEntity",
|
||||
"BinarySensorEntity",
|
||||
"ButtonEntity",
|
||||
"CalendarEntity",
|
||||
"CameraEntity",
|
||||
"ClimateEntity",
|
||||
"CoverEntity",
|
||||
"DeviceTrackerEntity",
|
||||
"Entity",
|
||||
"EventEntity",
|
||||
"FanEntity",
|
||||
"HumidifierEntity",
|
||||
"ImageEntity",
|
||||
"LawnMowerEntity",
|
||||
"LightEntity",
|
||||
"LockEntity",
|
||||
"MediaPlayerEntity",
|
||||
"NotifyEntity",
|
||||
"NumberEntity",
|
||||
"RemoteEntity",
|
||||
"SelectEntity",
|
||||
"SensorEntity",
|
||||
"SirenEntity",
|
||||
"SwitchEntity",
|
||||
"TextEntity",
|
||||
"TodoListEntity",
|
||||
"UpdateEntity",
|
||||
"VacuumEntity",
|
||||
"ValveEntity",
|
||||
"WaterHeaterEntity",
|
||||
"WeatherEntity",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_attribute_decorated(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||
"""True when the method is declared ``@property`` / ``@cached_property``."""
|
||||
for decorator in node.decorator_list:
|
||||
name = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
|
||||
if name in {"property", "cached_property"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _class_bases(node: ast.ClassDef) -> set[str]:
|
||||
"""The names in a class's base list (``**kwargs``-style bases are ignored)."""
|
||||
return {
|
||||
base.id if isinstance(base, ast.Name) else getattr(base, "attr", "")
|
||||
for base in node.bases
|
||||
}
|
||||
|
||||
|
||||
def entity_class_names(tree: ast.Module) -> set[str]:
|
||||
"""Names of the classes in one module that inherit from an HA entity base."""
|
||||
bases = {
|
||||
node.name: _class_bases(node) for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
||||
}
|
||||
entities = {name for name, parents in bases.items() if parents & HA_ENTITY_BASES}
|
||||
# Transitive: our own intermediate bases (NvidiaShieldSensorBase → SensorEntity)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, parents in bases.items():
|
||||
if name not in entities and parents & entities:
|
||||
entities.add(name)
|
||||
changed = True
|
||||
return entities
|
||||
|
||||
|
||||
def _scan_entity_contracts(paths, report: Report) -> None:
|
||||
"""t40: flag HA-read attributes declared as plain methods.
|
||||
|
||||
The stub suites cannot see this class of defect — they call the method
|
||||
successfully, because the harnesses replace ``async_write_ha_state`` — and
|
||||
only real HA turns it into a TypeError. Pure AST, so it also runs when
|
||||
homeassistant is not installed.
|
||||
"""
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
continue # scan() reports bad-path for the same target
|
||||
files = _iter_python_files(root)
|
||||
for file in files:
|
||||
try:
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
entity_classes = entity_class_names(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef) or node.name not in entity_classes:
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
if item.name not in ENTITY_ATTRIBUTES or _is_attribute_decorated(item):
|
||||
continue
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(file),
|
||||
item.lineno,
|
||||
"entity-attribute-is-a-method",
|
||||
f"{node.name}.{item.name} is a plain method, but HA reads it as an "
|
||||
"attribute (``attr |= self.<name>``) — declare it @property",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def scan_entity_contracts(paths=None) -> list[Finding]:
|
||||
"""The entity-contract pass on its own (t40) — the fast suite's entry point."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_scan_entity_contracts(paths, report)
|
||||
return report.findings
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- scan
|
||||
|
||||
|
||||
@@ -455,6 +867,11 @@ def scan(paths=None) -> Report:
|
||||
paths = [paths]
|
||||
|
||||
report = Report()
|
||||
# t40/t41: pure-AST checks, so they run even without homeassistant — that is
|
||||
# the whole point of a contract stubs cannot enforce (and a relative import
|
||||
# needs no library to be wrong).
|
||||
_scan_entity_contracts(paths, report)
|
||||
_scan_package_imports(paths, report)
|
||||
if not homeassistant_available():
|
||||
# Neither a false green nor a wall of phantom findings: with no package
|
||||
# to resolve against, every import would "fail", so the sweep reports
|
||||
@@ -484,7 +901,7 @@ def scan(paths=None) -> Report:
|
||||
)
|
||||
)
|
||||
continue
|
||||
files = sorted(root.glob("*.py")) if root.is_dir() else [root]
|
||||
files = _iter_python_files(root)
|
||||
for file in files:
|
||||
report.modules += 1
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
|
||||
+248
-42
@@ -13,7 +13,7 @@ import sys
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
||||
from entity_stublib import install, load_entity_modules
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
from nsh.const import TIVIMATE_PACKAGE
|
||||
|
||||
@@ -21,15 +21,10 @@ from nsh.const import TIVIMATE_PACKAGE
|
||||
@pytest.fixture(autouse=True)
|
||||
def scoped_entity_stubs():
|
||||
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
|
||||
snapshot = {name: sys.modules.get(name) for name in ENTITIES_STUB_NAMES}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
install()
|
||||
yield load_entity_modules()
|
||||
for name in ENTITIES_STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
class FakeProtocolClient:
|
||||
@@ -116,6 +111,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player._entry = entry
|
||||
player._manager = manager
|
||||
player._intent = None
|
||||
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
|
||||
player._last_channel = None
|
||||
player.hass = entity_stublib.FakeHass()
|
||||
player._entity_tasks = set()
|
||||
@@ -123,13 +119,18 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player.async_write_ha_state = lambda: None
|
||||
player._read = lambda eid: None
|
||||
|
||||
# t41: nothing may leave this integration, so an HA service call is now the
|
||||
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||
# regression reintroduces the external-entity delegation.
|
||||
served = []
|
||||
|
||||
async def _cmd(service, **kwargs):
|
||||
served.append(("legacy", service))
|
||||
return None
|
||||
class _RecordingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
# t44: the payload matters now (command scripts pass HA script
|
||||
# variables), so record it too — every existing assertion is `== []`.
|
||||
served.append((domain, service, data))
|
||||
|
||||
player._cmd = _cmd
|
||||
player.hass.services = _RecordingServices()
|
||||
|
||||
captured_intents = []
|
||||
|
||||
@@ -248,7 +249,8 @@ class TestChannelDigits:
|
||||
|
||||
await player._channel_entry_sequence(["7"])
|
||||
|
||||
assert served == [("legacy", "play_media")] # transitional path used
|
||||
# t41: both transports down is a no-op — no other integration is called
|
||||
assert served == []
|
||||
|
||||
|
||||
class TestAppAndUrls:
|
||||
@@ -346,38 +348,67 @@ class TestRemoteEntity:
|
||||
assert protocol.key_calls == []
|
||||
|
||||
|
||||
class TestIntentRetryWithoutATransport:
|
||||
"""t41: an intent no transport served surrenders instead of delegating."""
|
||||
|
||||
def _intent(self, **overrides):
|
||||
from types import SimpleNamespace
|
||||
|
||||
intent = SimpleNamespace(
|
||||
target_state="playing",
|
||||
command="media_play",
|
||||
created_at=0.0,
|
||||
prior_state=None,
|
||||
retry_count=0,
|
||||
via_adb=False,
|
||||
via_protocol=False,
|
||||
adb_command=None,
|
||||
protocol_key=None,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(intent, key, value)
|
||||
return intent
|
||||
|
||||
async def test_unserved_intent_surrenders_without_a_service_call(self, modules):
|
||||
player, served, _intents = make_player(modules)
|
||||
player.async_write_ha_state = lambda: None
|
||||
player._intent = self._intent()
|
||||
|
||||
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||
|
||||
assert player._intent is None # surrendered to the truth
|
||||
assert served == [] # and nothing left this integration
|
||||
|
||||
async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules):
|
||||
player, served, _intents = make_player(modules)
|
||||
player.async_write_ha_state = lambda: None
|
||||
protocol = player._manager._protocol
|
||||
player._intent = self._intent(
|
||||
via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY"
|
||||
)
|
||||
|
||||
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
|
||||
assert served == []
|
||||
|
||||
|
||||
class TestAppFallback:
|
||||
async def test_app_id_prefers_the_androidtv_entity(self, modules):
|
||||
"""t41: the app identity comes from OUR transports' store only."""
|
||||
|
||||
async def test_app_id_comes_from_the_manager_store(self, modules):
|
||||
player, _s, _i = make_player(modules)
|
||||
player._androidtv_entity = "media_player.atv"
|
||||
from types import SimpleNamespace as FakeState
|
||||
|
||||
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
|
||||
states = FakeState(states=FakeState(
|
||||
get=lambda eid: state if eid == "media_player.atv" else None
|
||||
))
|
||||
player.hass = states
|
||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||
|
||||
assert player.app_id == "com.plex.hdx"
|
||||
assert player.app_name == "Plex"
|
||||
|
||||
async def test_app_falls_back_to_protocol_current_app(self, modules):
|
||||
player, _s, _i = make_player(modules)
|
||||
player._androidtv_entity = "" # standalone setup (P3-1)
|
||||
player._manager._on_current_app("com.netflix.ninja")
|
||||
player._manager._on_current_app("com.plex.hdx")
|
||||
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
|
||||
|
||||
assert player.app_id == "com.netflix.ninja"
|
||||
assert player.app_name == "com.netflix.ninja" # package is truth; map later
|
||||
assert player.app_id == "com.plex.hdx"
|
||||
assert player.app_name == "com.plex.hdx" # package is truth; map later
|
||||
|
||||
async def test_app_unavailable_entity_falls_back(self, modules):
|
||||
async def test_app_follows_the_latest_protocol_push(self, modules):
|
||||
player, _s, _i = make_player(modules)
|
||||
player._androidtv_entity = "media_player.atv"
|
||||
from types import SimpleNamespace as FakeState
|
||||
|
||||
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
|
||||
player._read = lambda eid: None
|
||||
player._manager._on_current_app("com.netflix.ninja")
|
||||
await asyncio.sleep(0.05)
|
||||
player._manager._on_current_app("com.google.android.youtube.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@@ -393,7 +424,6 @@ def _ladder_player(modules, foreground=None, audio=None):
|
||||
manager.store.set("foreground", foreground)
|
||||
if audio:
|
||||
manager.store.set("audio_playback", audio)
|
||||
player._androidtv_entity = ""
|
||||
player._read = lambda eid: None
|
||||
return player, manager
|
||||
|
||||
@@ -411,7 +441,6 @@ class TestForegroundLadderTier:
|
||||
manager.store.set("foreground", foreground)
|
||||
if audio:
|
||||
manager.store.set("audio_playback", audio)
|
||||
player._androidtv_entity = ""
|
||||
player._read = lambda eid: None
|
||||
return player, manager
|
||||
|
||||
@@ -1002,7 +1031,8 @@ class TestEntryDiagnostics:
|
||||
options={
|
||||
"adb_command_enabled": True,
|
||||
"protocol_pairing_code": "4821",
|
||||
"adb_host": "192.168.1.50",
|
||||
"host": "192.168.1.50", # t41: the one address field
|
||||
"adb_host": "192.168.1.50", # legacy spellings stay redacted too
|
||||
"protocol_host": "192.168.1.50",
|
||||
},
|
||||
title="Shield",
|
||||
@@ -1020,6 +1050,7 @@ class TestEntryDiagnostics:
|
||||
# old import pointed at homeassistant.util.diagnostic, which does not
|
||||
# exist, so this pass silently did nothing and only the marker scan
|
||||
# (pairing-/key-shaped names) masked anything.
|
||||
assert payload["entry"]["options"]["host"] == "**REDACTED**"
|
||||
assert payload["entry"]["options"]["adb_host"] == "**REDACTED**"
|
||||
assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**"
|
||||
assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**"
|
||||
@@ -1172,3 +1203,178 @@ class TestBrowseMediaDirectory:
|
||||
await player.async_play_media("app", selected.media_content_id)
|
||||
|
||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||
|
||||
|
||||
class DownAdbPoller(FakeAdbPoller):
|
||||
"""A CONFIGURED ADB poller that cannot reach the device (t40 repro)."""
|
||||
|
||||
connected = False
|
||||
|
||||
|
||||
def make_manager_with(*, poller=None, protocol=None):
|
||||
"""A manager with exactly these transports (None = not configured)."""
|
||||
return ShieldConnectionManager(
|
||||
hass=None, entry=None, poller=poller, protocol=protocol, store=None
|
||||
)
|
||||
|
||||
|
||||
def make_app_sensor(modules, manager, entry):
|
||||
"""A Current App sensor over the given manager/entry (t40).
|
||||
|
||||
t41: the sensor reads the manager's store and nothing else, so the harness
|
||||
hands it a hass whose state machine is empty — a value here can only come
|
||||
from our own transports.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None))
|
||||
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
|
||||
|
||||
|
||||
class TestCurrentAppSensorFriendlyName:
|
||||
"""t40: the sensor shows the RESOLVED name; the raw package is an attribute.
|
||||
|
||||
It used to publish ``manager.foreground_app`` — the raw package id — so a
|
||||
live install showed "ar.tvplayer.tv" where the media player showed
|
||||
"TiviMate". Both now resolve through the shared ``resolve_app_name`` chain.
|
||||
"""
|
||||
|
||||
def _sensor(self, modules, player):
|
||||
# The sensor resolves the manager through entry.runtime_data — the same
|
||||
# wiring a real setup gives it.
|
||||
player._entry.runtime_data = player._manager
|
||||
return make_app_sensor(modules, player._manager, player._entry)
|
||||
|
||||
def test_mapped_package_shows_the_friendly_name(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
player._manager._on_current_app("ar.tvplayer.tv")
|
||||
|
||||
assert sensor.native_value == "TiviMate"
|
||||
assert sensor.extra_state_attributes["app_id"] == "ar.tvplayer.tv"
|
||||
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
|
||||
|
||||
def test_cached_manifest_label_is_the_second_tier(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock())
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
manager._on_current_app("com.netflix.ninja")
|
||||
|
||||
assert sensor.native_value == "Netflix"
|
||||
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
|
||||
|
||||
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
manager = player._manager
|
||||
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||
manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}])
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
assert sensor.native_value == "TiviMate"
|
||||
|
||||
def test_unknown_package_falls_back_to_the_package_text(self, modules):
|
||||
player, _served, _intents = make_player(modules)
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
player._manager._on_current_app("com.example.unknown")
|
||||
|
||||
assert sensor.native_value == "com.example.unknown" # not None
|
||||
assert sensor.extra_state_attributes == {
|
||||
"app_id": "com.example.unknown",
|
||||
"app_name": "com.example.unknown",
|
||||
}
|
||||
|
||||
def test_sensor_and_media_player_resolve_identically(self, modules):
|
||||
"""One chain, two entities — they cannot disagree about the app name."""
|
||||
player, _served, _intents = make_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
sensor = self._sensor(modules, player)
|
||||
|
||||
player._manager._on_current_app("ar.tvplayer.tv")
|
||||
|
||||
assert sensor.native_value == player.app_name == "TiviMate"
|
||||
|
||||
|
||||
class TestAvailabilityLadder:
|
||||
"""t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE.
|
||||
|
||||
The t5-F2 rule took the whole entity down whenever a configured ADB manager
|
||||
was disconnected, even though the paired protocol transport was still
|
||||
serving power/app/volume.
|
||||
"""
|
||||
|
||||
def _player(self, modules, *, poller=None, protocol=None):
|
||||
player, _served, _intents = make_player(
|
||||
modules, manager=make_manager_with(poller=poller, protocol=protocol)
|
||||
)
|
||||
player._entry.runtime_data = player._manager
|
||||
return player
|
||||
|
||||
def test_the_users_case_keeps_the_entity_available(self, modules):
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||
player._manager._on_is_available(True)
|
||||
|
||||
assert player._manager.available is True # ADB is configured ...
|
||||
assert player._manager.connected is False # ... but unreachable
|
||||
assert player.available is True # the protocol carries the entity
|
||||
|
||||
def test_unavailable_when_every_configured_transport_is_down(self, modules):
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||
player._manager._on_is_available(False)
|
||||
|
||||
assert player.available is False
|
||||
|
||||
def test_fresh_wake_data_is_enough(self, modules):
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||
player._manager._on_is_available(False)
|
||||
player._manager.store.set("awake", False)
|
||||
|
||||
assert player.available is True
|
||||
|
||||
def test_a_connected_adb_carries_a_protocol_less_entry(self, modules):
|
||||
player = self._player(modules, poller=FakeAdbPoller(), protocol=None)
|
||||
|
||||
assert player._manager.protocol_active is False
|
||||
assert player.available is True
|
||||
|
||||
def test_a_passive_manager_stays_available(self, modules):
|
||||
player = self._player(modules, poller=None, protocol=None)
|
||||
|
||||
assert player._manager.available is False # no ADB configured at all
|
||||
assert player.available is True
|
||||
|
||||
def test_a_configured_but_down_transport_is_unavailable(self, modules):
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=None)
|
||||
|
||||
assert player.available is False
|
||||
|
||||
def test_the_remote_follows_the_same_ladder(self, modules):
|
||||
from types import SimpleNamespace
|
||||
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||
player._manager._on_is_available(True)
|
||||
remote = modules["remote"].NvidiaShieldRemoteEntity(
|
||||
SimpleNamespace(), player._entry
|
||||
)
|
||||
|
||||
assert remote.available is True
|
||||
|
||||
def test_the_diagnostics_dump_explains_the_ladder(self, modules):
|
||||
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
|
||||
player._manager._on_is_available(True)
|
||||
|
||||
from nsh.diagnostics import async_get_config_entry_diagnostics
|
||||
|
||||
entry = player._entry
|
||||
entry.data = {}
|
||||
entry.options = {}
|
||||
payload = asyncio.run(async_get_config_entry_diagnostics(None, entry))
|
||||
|
||||
transport = payload["transport"]
|
||||
assert transport["entity_available"] is True
|
||||
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
|
||||
assert transport["protocol"]["active"] is True
|
||||
@@ -0,0 +1,443 @@
|
||||
"""tests/test_command_scripts.py — per-command script overrides (t44).
|
||||
|
||||
The user's remaining universal-media-player dependency: some commands need hardware
|
||||
this integration deliberately does not drive — a speaker switch that owns the
|
||||
volume, an AVR input that has to move with the Shield, a mute that has to hit two
|
||||
devices. One script per command is the escape hatch, and the rule is absolute:
|
||||
|
||||
configured -> that command calls the script, with its variables, and never
|
||||
touches the protocol or ADB path;
|
||||
unset -> the command behaves exactly as it did before this existed.
|
||||
|
||||
The suite is expected to stay quiet about the first case elsewhere: nothing else may
|
||||
start calling services, which is why tests/test_command_routing.py asserts that no
|
||||
service call happens on the native paths.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import (
|
||||
CONF_COMMAND_SCRIPTS,
|
||||
CONF_REMOTE_COMMAND_SCRIPT,
|
||||
SCRIPT_VAR_APP_NAME,
|
||||
SCRIPT_VAR_COMMAND,
|
||||
SCRIPT_VAR_DELAY_SECS,
|
||||
SCRIPT_VAR_HOLD_SECS,
|
||||
SCRIPT_VAR_LEVEL,
|
||||
SCRIPT_VAR_MUTED,
|
||||
SCRIPT_VAR_NUM_REPEATS,
|
||||
)
|
||||
from test_command_routing import _remote_entity, make_manager, make_player
|
||||
|
||||
#: Commands with no data to pass — the script is called with the entity only.
|
||||
PLAIN_COMMANDS = ("turn_on", "turn_off", "volume_up", "volume_down")
|
||||
|
||||
WATCH = "script.shield_watch"
|
||||
|
||||
|
||||
@pytest.fixture(name="modules", autouse=True)
|
||||
def scoped_modules():
|
||||
"""Stub scope + loaded entity modules for one test, restored afterwards.
|
||||
|
||||
The t7 discipline: without the teardown this file's `nsh.*` modules and stub
|
||||
faces survive into whichever file runs next, and the next file's flow/entity
|
||||
classes bind against the wrong faces (the t44 file-order failure).
|
||||
"""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
try:
|
||||
yield entity_stublib.load_entity_modules()
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _services(served):
|
||||
"""A hass.services stand-in that records (domain, service, data)."""
|
||||
|
||||
class _RecordingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
served.append((domain, service, data))
|
||||
|
||||
return _RecordingServices()
|
||||
|
||||
|
||||
def _player(modules, scripts=None, *, is_on=None):
|
||||
player, served, _intents = make_player(modules)
|
||||
if is_on is not None:
|
||||
player._manager._on_is_on(is_on)
|
||||
if scripts is not None:
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||
player.hass.services = _services(served)
|
||||
return player, served
|
||||
|
||||
|
||||
def _script_call(script=WATCH, variables=None):
|
||||
data = {"entity_id": script}
|
||||
if variables:
|
||||
data["variables"] = variables
|
||||
return ("script", "turn_on", data)
|
||||
|
||||
|
||||
class TestMappedCommands:
|
||||
"""A configured script replaces the native path — for every command."""
|
||||
|
||||
@pytest.mark.parametrize("command", PLAIN_COMMANDS)
|
||||
async def test_a_plain_command_calls_its_script_and_nothing_else(
|
||||
self, modules, command
|
||||
):
|
||||
# every native path would have had work to do: ADB is up, and for the
|
||||
# power commands the state gate is open
|
||||
player, served = _player(modules, {command: WATCH}, is_on=command == "turn_off")
|
||||
|
||||
await getattr(player, f"async_{command}")()
|
||||
|
||||
assert served == [_script_call()]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_volume_set_passes_the_level(self, modules):
|
||||
player, served = _player(modules, {"volume_set": WATCH})
|
||||
|
||||
await player.async_set_volume_level(0.4)
|
||||
|
||||
# exactly the variable name and the value as passed, nothing extra
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_LEVEL: 0.4})]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
@pytest.mark.parametrize("mute", [True, False])
|
||||
async def test_volume_mute_passes_the_requested_state(self, modules, mute):
|
||||
"""A script cannot tell mute from unmute without this."""
|
||||
player, served = _player(modules, {"volume_mute": WATCH})
|
||||
|
||||
await player.async_mute_volume(mute)
|
||||
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_MUTED: mute})]
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_select_source_passes_the_app_name(self, modules):
|
||||
player, served = _player(modules, {"select_source": WATCH})
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [_script_call(variables={SCRIPT_VAR_APP_NAME: "TiviMate"})]
|
||||
|
||||
async def test_the_remote_entity_shares_the_same_turn_on_script(self, modules):
|
||||
"""There are two routes to POWER; one configuration governs both."""
|
||||
entity = _remote_entity(modules)
|
||||
served = []
|
||||
entity.hass = SimpleNamespace(services=_services(served))
|
||||
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
entity._manager._on_is_on(False) # the native path would fire
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert served == [_script_call()]
|
||||
assert entity._manager._protocol.key_calls == []
|
||||
|
||||
|
||||
class TestUnmappedCommands:
|
||||
"""With nothing configured, every command keeps its native path."""
|
||||
|
||||
async def test_no_service_call_happens_at_all(self, modules):
|
||||
player, served = _player(modules, {}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_volume_up()
|
||||
|
||||
assert served == [] # nothing left the integration
|
||||
|
||||
async def test_turn_on_still_sends_the_protocol_power_key(self, modules):
|
||||
player, _served = _player(modules, {}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||
|
||||
async def test_volume_up_still_uses_the_native_protocol_key(self, modules):
|
||||
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
|
||||
await player.async_volume_up()
|
||||
|
||||
assert player._manager._protocol.key_calls == [
|
||||
(PROTOCOL_REMOTE_KEYS["volume_up"], "SHORT")
|
||||
]
|
||||
|
||||
async def test_volume_up_still_falls_through_to_adb(self, modules):
|
||||
"""The second native tier, reached exactly when the protocol is down."""
|
||||
player, _served = _player(modules, {})
|
||||
player._manager._protocol = None
|
||||
|
||||
await player.async_volume_up()
|
||||
|
||||
assert player._manager._poller.media_calls == ["volume_up"]
|
||||
|
||||
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
|
||||
"""Absolute volume exists ONLY through a script (the protocol has no
|
||||
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
|
||||
player, served = _player(modules, {})
|
||||
|
||||
await player.async_set_volume_level(0.4)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
|
||||
from nsh.const import PROTOCOL_REMOTE_KEYS
|
||||
|
||||
player, served = _player(modules, {"volume_up": WATCH})
|
||||
|
||||
await player.async_volume_down()
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == [
|
||||
(PROTOCOL_REMOTE_KEYS["volume_down"], "SHORT")
|
||||
]
|
||||
|
||||
|
||||
class TestScriptFailures:
|
||||
"""A mapped command that cannot run must be visible, never a silent no-op."""
|
||||
|
||||
async def test_a_raising_script_surfaces_a_clean_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules, {"volume_up": WATCH}, is_on=False)
|
||||
|
||||
class _FailingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise RuntimeError("speaker switch is offline")
|
||||
|
||||
player.hass.services = _FailingServices()
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await player.async_volume_up()
|
||||
|
||||
# translated (t47): the command, the script and the cause are placeholders
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "command_script_failed"
|
||||
assert raised.value.translation_placeholders == {
|
||||
"action": "volume_up",
|
||||
"script": WATCH,
|
||||
"error": "speaker switch is offline",
|
||||
}
|
||||
# ...and it did NOT quietly fall back to the native path
|
||||
assert player._manager._protocol.key_calls == []
|
||||
assert player._manager._poller.media_calls == []
|
||||
|
||||
|
||||
class TestOptionShape:
|
||||
"""The stored option is read through one normalizer, defensively."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored",
|
||||
[
|
||||
"script.watch", # not a dict at all
|
||||
{"turn_on": 5}, # not an entity id
|
||||
{"turn_on": " "}, # empty
|
||||
{"bogus_command": WATCH}, # unknown command
|
||||
None,
|
||||
],
|
||||
)
|
||||
async def test_a_hand_edited_option_never_reaches_the_service_layer(
|
||||
self, modules, stored
|
||||
):
|
||||
player, served = _player(modules, stored, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
|
||||
|
||||
async def test_a_padded_entity_id_is_trimmed(self, modules):
|
||||
player, served = _player(modules, {"turn_on": f" {WATCH} "}, is_on=False)
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == [_script_call()]
|
||||
|
||||
|
||||
class TestFeatureSet:
|
||||
"""A script-only command still has to be reachable, so it is advertised."""
|
||||
|
||||
def _features(self, player):
|
||||
return int(player.supported_features)
|
||||
|
||||
def test_volume_set_appears_only_when_configured(self, modules):
|
||||
"""volume_set has no native path at all, so the script is the only reach."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
baseline = self._features(player)
|
||||
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
|
||||
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
|
||||
with_volume = self._features(player)
|
||||
|
||||
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
|
||||
# ...and nothing else moved
|
||||
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
|
||||
|
||||
def test_select_source_is_advertised_once_there_is_something_to_offer(
|
||||
self, modules
|
||||
):
|
||||
"""t47: either a script OR a source list is enough — the native path exists
|
||||
now, so an unconfigured integration still has sources to offer."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, {})
|
||||
flagged = self._features(player)
|
||||
|
||||
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||
# a configured script changes nothing about the flag
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
|
||||
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
|
||||
|
||||
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
|
||||
player, _served = _player(modules, {})
|
||||
baseline = self._features(player)
|
||||
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
|
||||
assert self._features(player) == baseline
|
||||
|
||||
|
||||
def test_the_remote_entry_options_are_the_media_player_options(modules):
|
||||
"""Both entities read the same option key — pinned so they cannot drift."""
|
||||
player, _served = _player(modules, {"turn_on": WATCH})
|
||||
entity = _remote_entity(modules)
|
||||
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
|
||||
|
||||
assert (
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
== entity._entry.options[CONF_COMMAND_SCRIPTS]
|
||||
)
|
||||
assert make_manager().entry is None # the harness's manager starts unbound
|
||||
|
||||
|
||||
WATCH_SCRIPT = "script.shield_remote"
|
||||
|
||||
|
||||
class TestRemoteCommandScript:
|
||||
"""t47 B: one optional script for remote.send_command, replacing the key path.
|
||||
|
||||
A remote command is a SEQUENCE (repeats, long presses, delays), so the escape
|
||||
hatch is one script for the whole call rather than a per-key override: an IR
|
||||
bridge, a macro or a second device needs the call, not the keys.
|
||||
"""
|
||||
|
||||
def _entity(self, modules, script=None):
|
||||
entity = _remote_entity(modules)
|
||||
served = []
|
||||
entity.hass = SimpleNamespace(services=_services(served))
|
||||
if script is not None:
|
||||
entity._entry.options[CONF_REMOTE_COMMAND_SCRIPT] = script
|
||||
return entity, served
|
||||
|
||||
async def test_a_configured_script_receives_the_exact_parameters(self, modules):
|
||||
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
await entity.async_send_command(
|
||||
["HOME", "KEYCODE_DPAD_CENTER"],
|
||||
num_repeats=3,
|
||||
delay_secs=0.25,
|
||||
hold_secs=1.5,
|
||||
)
|
||||
|
||||
assert served == [
|
||||
(
|
||||
"script",
|
||||
"turn_on",
|
||||
{
|
||||
"entity_id": WATCH_SCRIPT,
|
||||
"variables": {
|
||||
SCRIPT_VAR_COMMAND: ["HOME", "KEYCODE_DPAD_CENTER"],
|
||||
SCRIPT_VAR_NUM_REPEATS: 3,
|
||||
SCRIPT_VAR_DELAY_SECS: 0.25,
|
||||
SCRIPT_VAR_HOLD_SECS: 1.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
assert entity._manager._protocol.key_calls == [] # key path untouched
|
||||
|
||||
async def test_the_service_defaults_are_named_too(self, modules):
|
||||
"""The variable set is fixed: a script can rely on all four being present."""
|
||||
entity, served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
await entity.async_send_command(["home"])
|
||||
|
||||
variables = served[0][2]["variables"]
|
||||
assert variables == {
|
||||
SCRIPT_VAR_COMMAND: ["home"],
|
||||
SCRIPT_VAR_NUM_REPEATS: 1,
|
||||
SCRIPT_VAR_DELAY_SECS: 0.0,
|
||||
SCRIPT_VAR_HOLD_SECS: 0.0,
|
||||
}
|
||||
|
||||
async def test_unset_keeps_the_native_key_path(self, modules):
|
||||
entity, served = self._entity(modules)
|
||||
|
||||
await entity.async_send_command(["HOME"], num_repeats=2, hold_secs=0.0)
|
||||
|
||||
assert served == []
|
||||
assert entity._manager._protocol.key_calls == [
|
||||
("KEYCODE_HOME", "SHORT"),
|
||||
("KEYCODE_HOME", "SHORT"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("stored", ["", " ", None, 7])
|
||||
async def test_a_blank_or_hand_edited_option_means_unset(self, modules, stored):
|
||||
entity, served = self._entity(modules, stored)
|
||||
|
||||
await entity.async_send_command(["HOME"])
|
||||
|
||||
assert served == []
|
||||
assert entity._manager._protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
|
||||
|
||||
async def test_a_raising_script_is_a_translated_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
entity, _served = self._entity(modules, WATCH_SCRIPT)
|
||||
|
||||
class _FailingServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise RuntimeError("IR bridge is offline")
|
||||
|
||||
entity.hass.services = _FailingServices()
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await entity.async_send_command(["HOME"])
|
||||
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "command_script_failed"
|
||||
assert raised.value.translation_placeholders == {
|
||||
"action": "remote.send_command",
|
||||
"script": WATCH_SCRIPT,
|
||||
"error": "IR bridge is offline",
|
||||
}
|
||||
assert entity._manager._protocol.key_calls == [] # never a silent no-op
|
||||
|
||||
|
||||
def test_both_new_exception_keys_have_copy_in_both_files():
|
||||
"""A raised translation_key with no copy shows the user a bare key."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
|
||||
for name in ("strings.json", "translations/en.json"):
|
||||
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
|
||||
for key in ("unknown_source", "command_script_failed"):
|
||||
assert exceptions[key]["title"], (name, key)
|
||||
assert exceptions[key]["message"], (name, key)
|
||||
assert "{source}" in json.loads(
|
||||
(root / "strings.json").read_text(encoding="utf-8")
|
||||
)["exceptions"]["unknown_source"]["message"]
|
||||
assert "remote_command_script" in json.loads(
|
||||
(root / "translations/en.json").read_text(encoding="utf-8")
|
||||
)["options"]["step"]["init"]["data"]
|
||||
@@ -11,14 +11,14 @@ import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_POWER_ENTITY,
|
||||
CONF_RECEIVER_ENTITY,
|
||||
@@ -127,11 +127,7 @@ def _install_import_stubs() -> None:
|
||||
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")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -164,9 +160,8 @@ def _flow(user_input_host: str, monkeypatch=None):
|
||||
flow._user_data = {
|
||||
"name": "Nvidia Shield",
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_ADB_HOST: user_input_host,
|
||||
CONF_HOST: user_input_host,
|
||||
CONF_ADB_PORT: 5555,
|
||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||
CONF_JELLYFIN_ENTITY: "",
|
||||
CONF_RECEIVER_ENTITY: "",
|
||||
CONF_RECEIVER_INPUT: "",
|
||||
@@ -189,15 +184,10 @@ def scoped_stubs():
|
||||
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}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_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
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
@@ -18,7 +19,7 @@ from nsh.const import (
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ADB_STORE_APP_INFO,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_JELLYFIN_ENTITY,
|
||||
CONF_PAIRING_CODE,
|
||||
CONF_POWER_ENTITY,
|
||||
@@ -219,11 +220,7 @@ def _install_stubs() -> None:
|
||||
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")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -278,15 +275,10 @@ def _entry_module():
|
||||
@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}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_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
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
|
||||
@@ -296,17 +288,15 @@ def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtoco
|
||||
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_HOST: "shield.local", # t41: ONE address field
|
||||
CONF_ADB_PORT: DEFAULT_ADB_PORT,
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "shield.local",
|
||||
}
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
|
||||
@@ -350,7 +340,7 @@ class TestProtocolPairing:
|
||||
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"
|
||||
assert result["options"][CONF_HOST] == "shield.local"
|
||||
|
||||
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
|
||||
client = FakeProtocolClient()
|
||||
@@ -374,7 +364,7 @@ class TestReauthFlow:
|
||||
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"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
data={},
|
||||
)
|
||||
flow.hass.config_entries.__dict__["__entry_e1"] = entry
|
||||
@@ -396,12 +386,25 @@ class TestReauthFlow:
|
||||
|
||||
|
||||
class TestEntryMigration:
|
||||
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
|
||||
"""t41: v2 → v3 collapses the two address fields into CONF_HOST.
|
||||
|
||||
The protocol host wins, else the ADB host (both name the same Shield); the
|
||||
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
|
||||
travels the whole way in one call.
|
||||
"""
|
||||
|
||||
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=1,
|
||||
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
|
||||
data={},
|
||||
options={
|
||||
"adb_enabled": True,
|
||||
"adb_port": 5555,
|
||||
"jellyfin_entity": "jf.x",
|
||||
"adb_host": "10.0.0.31", # legacy address
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
@@ -411,26 +414,82 @@ class TestEntryMigration:
|
||||
# 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["jellyfin_entity"] == "jf.x"
|
||||
# the v2 default, then the v3 address collapse
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is False
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == ""
|
||||
assert hass.config_entries.updating_entry_calls == [
|
||||
(entry, {"options": entry.options, "version": 2})
|
||||
]
|
||||
assert entry.options[CONF_HOST] == "10.0.0.31"
|
||||
assert "adb_host" not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
|
||||
async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
options={CONF_PROTOCOL_ENABLED: True},
|
||||
data={},
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
|
||||
CONF_ADB_HOST: "10.0.0.1",
|
||||
},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.2"
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == "10.0.0.1"
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
|
||||
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
|
||||
assert entry.options[CONF_HOST] == ""
|
||||
assert CONF_ADB_HOST not in entry.options
|
||||
assert CONF_PROTOCOL_HOST not in entry.options
|
||||
assert entry.version == 3
|
||||
|
||||
async def test_the_current_version_is_a_noop(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=3,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
assert await module.async_migrate_entry(hass, entry) is True
|
||||
assert hass.config_entries.updating_entry_calls == []
|
||||
|
||||
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
|
||||
async def test_downgrade_rejected(self, tmp_path):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(version=3, options={}, title="x")
|
||||
entry = SimpleNamespace(version=4, options={}, title="x")
|
||||
assert await module.async_migrate_entry(hass, entry) is False
|
||||
+714
-83
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,126 @@
|
||||
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
|
||||
|
||||
The stub suites drive entities through hand-written fakes and replace
|
||||
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
||||
perfectly healthy there while a live install raises
|
||||
``TypeError: 'method' object is not iterable`` on every state write and the
|
||||
entity never renders.
|
||||
|
||||
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
|
||||
suite and the drift sweep share ONE implementation — and the CI drift job, which
|
||||
runs against a real Home Assistant, gates the same code path.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import ha_api_drift
|
||||
from conftest import _SHIELD_DIR
|
||||
|
||||
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
|
||||
|
||||
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
|
||||
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
|
||||
_PRE_FIX = f'''
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
|
||||
|
||||
class Player(MediaPlayerEntity):
|
||||
{_PLAIN_DEF}
|
||||
return {{"app_id": "x"}}
|
||||
'''
|
||||
|
||||
_FIXED = f'''
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
|
||||
|
||||
class Player(MediaPlayerEntity):
|
||||
@property
|
||||
{_PLAIN_DEF}
|
||||
return {{"app_id": "x"}}
|
||||
'''
|
||||
|
||||
_INHERITED_BASE = '''
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
|
||||
|
||||
class Base(SensorEntity):
|
||||
"""Our own intermediate base — subclasses are entities too."""
|
||||
|
||||
|
||||
class Child(Base):
|
||||
def native_value(self):
|
||||
return "x"
|
||||
'''
|
||||
|
||||
|
||||
def _findings(tmp_path, source: str):
|
||||
(tmp_path / "module.py").write_text(source, encoding="utf-8")
|
||||
return ha_api_drift.scan_entity_contracts(tmp_path)
|
||||
|
||||
|
||||
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
|
||||
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
|
||||
assert ha_api_drift.scan_entity_contracts() == []
|
||||
|
||||
|
||||
def test_the_check_flags_the_pre_fix_shape(tmp_path):
|
||||
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
|
||||
|
||||
findings = _findings(tmp_path, _PRE_FIX)
|
||||
|
||||
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
|
||||
assert findings[0].lineno == expected_line
|
||||
assert "Player.extra_state_attributes" in findings[0].detail
|
||||
assert "@property" in findings[0].detail
|
||||
|
||||
|
||||
def test_the_check_accepts_the_property_form(tmp_path):
|
||||
assert _findings(tmp_path, _FIXED) == []
|
||||
|
||||
|
||||
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
|
||||
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
|
||||
findings = _findings(tmp_path, _INHERITED_BASE)
|
||||
|
||||
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
|
||||
|
||||
|
||||
def test_the_tree_classification_is_not_vacuous():
|
||||
"""A classifier that sees no entities would pass every check above."""
|
||||
classified: set[str] = set()
|
||||
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
|
||||
|
||||
assert {
|
||||
"NvidiaShieldMediaPlayer",
|
||||
"NvidiaShieldCurrentAppSensor",
|
||||
"NvidiaShieldNowPlayingSensor",
|
||||
"NvidiaShieldRemoteEntity",
|
||||
"NvidiaShieldPairAdbButton",
|
||||
} <= classified
|
||||
|
||||
|
||||
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
|
||||
"""One implementation: the sweep's report carries the contract pass too."""
|
||||
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
|
||||
|
||||
report = ha_api_drift.scan(tmp_path)
|
||||
kinds = [finding.kind for finding in report.findings]
|
||||
|
||||
# The contract pass runs FIRST (pure AST). What the API passes add after it
|
||||
# depends on the interpreter: with homeassistant installed they judge the
|
||||
# module's imports, without it the sweep says it cannot judge (both are
|
||||
# covered here so the check holds in either variant).
|
||||
assert kinds[0] == "entity-attribute-is-a-method"
|
||||
rest = set(kinds[1:])
|
||||
if ha_api_drift.homeassistant_available():
|
||||
# the API passes ran and found nothing wrong with this module
|
||||
assert rest == set(), rest
|
||||
else:
|
||||
assert "no-homeassistant" in rest
|
||||
|
||||
|
||||
def test_the_check_ignores_missing_paths_instead_of_raising():
|
||||
"""scan() owns the bad-path finding; the pass just skips."""
|
||||
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []
|
||||
@@ -12,9 +12,12 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import ha_api_drift
|
||||
import pytest
|
||||
@@ -206,3 +209,152 @@ def test_the_declared_python_floor_never_lags_homeassistant():
|
||||
f"but the installed homeassistant requires {declared} — raise the constant "
|
||||
f"and the setup-python version in .gitea/workflows/test.yml"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------- entity contract (t40, real HA)
|
||||
#
|
||||
# The stub suites cannot see this class of defect: their harness replaces
|
||||
# ``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
||||
# fine there. Only HA's own ``Entity.__async_calculate_state`` — which does
|
||||
# ``attr |= self.extra_state_attributes`` — turns it into
|
||||
# ``TypeError: 'method' object is not iterable`` and stops the entity from ever
|
||||
# rendering. These tests drive that real path.
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class _RecordingStates:
|
||||
"""Minimal ``hass.states``: keeps what HA's entity writes."""
|
||||
|
||||
def __init__(self):
|
||||
self.registry: dict = {}
|
||||
self.written: list = []
|
||||
|
||||
def get(self, entity_id):
|
||||
return self.registry.get(entity_id)
|
||||
|
||||
def async_set_internal(self, entity_id, state, attr, *args):
|
||||
self.written.append((entity_id, state, dict(attr)))
|
||||
|
||||
|
||||
def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
|
||||
"""Build a production entity under real homeassistant (no platform)."""
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
module = importlib.import_module(f"custom_components.nvidia_shield.{module_name}")
|
||||
states = _RecordingStates()
|
||||
hass = SimpleNamespace(
|
||||
loop_thread_id=threading.get_ident(),
|
||||
states=states,
|
||||
data={},
|
||||
config=SimpleNamespace(components=set()),
|
||||
)
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
|
||||
)
|
||||
entity = getattr(module, class_name)(hass, entry)
|
||||
entity.entity_id = entity_id
|
||||
# HA's real translation-key paths (``unit_of_measurement``, device-class
|
||||
# names) need the platform's translation data; without it a translation-keyed
|
||||
# entity raises "cannot have a translation key ... before being added to the
|
||||
# entity platform". PlatformData is the real container, so the entity is
|
||||
# exercised with the same shape it sees in production.
|
||||
from homeassistant.helpers.entity_platform import PlatformData
|
||||
|
||||
entity.platform_data = PlatformData(
|
||||
hass, domain=module_name, platform_name="nvidia_shield"
|
||||
)
|
||||
# Not added through an EntityComponent: HA would warn once via
|
||||
# _suggest_report_issue (which needs hass.data["integrations"]) — set the
|
||||
# flag it uses to report that once instead of faking a whole platform.
|
||||
entity._no_platform_reported = True
|
||||
return entity, states
|
||||
|
||||
|
||||
def _write_ha_state(entity, states):
|
||||
"""Run HA's real state write and return what reached the state machine."""
|
||||
entity.async_write_ha_state()
|
||||
assert states.written, f"{type(entity).__name__} wrote no state"
|
||||
return states.written[-1]
|
||||
|
||||
|
||||
#: Every concrete entity class in the integration, exercised above against the
|
||||
#: REAL state machine. t40: the media player's defect was a state-write crash, so
|
||||
#: "the whole tree renders" is the contract under test, not a single entity.
|
||||
_REAL_HA_MATRIX = (
|
||||
("media_player", "NvidiaShieldMediaPlayer", None, "media_player.shield"),
|
||||
("sensor", "NvidiaShieldCurrentAppSensor", None, "sensor.shield_current_app"),
|
||||
(
|
||||
"sensor",
|
||||
"NvidiaShieldNowPlayingSensor",
|
||||
{"jellyfin_entity": "media_player.jellyfin"},
|
||||
"sensor.shield_now_playing",
|
||||
),
|
||||
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
|
||||
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
|
||||
)
|
||||
|
||||
#: Classified as an entity by the sweep, but an abstract intermediate base HA
|
||||
#: never instantiates — its concrete subclasses are in the matrix.
|
||||
_INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX
|
||||
)
|
||||
def test_entities_calculate_state_under_real_homeassistant(
|
||||
module_name, class_name, options, entity_id
|
||||
):
|
||||
"""t40 blocker: HA's own state write must not raise for ANY entity.
|
||||
|
||||
``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the
|
||||
extra attributes with ``attr |= ...``; the pre-fix media player raised
|
||||
``TypeError: 'method' object is not iterable`` here, so it could never render.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
module_name, class_name, options=options, entity_id=entity_id
|
||||
)
|
||||
|
||||
written_id, state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert written_id == entity_id
|
||||
assert isinstance(state, str)
|
||||
assert isinstance(attrs, dict)
|
||||
|
||||
|
||||
def test_the_media_player_extra_attributes_reach_the_state_machine():
|
||||
"""The property's payload must survive HA's merge, not merely not raise."""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
entity, states = _real_ha_entity(
|
||||
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
|
||||
)
|
||||
|
||||
_entity_id, _state, attrs = _write_ha_state(entity, states)
|
||||
|
||||
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
|
||||
|
||||
|
||||
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
|
||||
"""A new entity class must join the matrix above, or this test fails.
|
||||
|
||||
The sweep classifies entities from the AST; this keeps the real-HA coverage
|
||||
in step with that classification instead of silently shrinking.
|
||||
"""
|
||||
_require_installed_homeassistant()
|
||||
|
||||
classified: set[str] = set()
|
||||
for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")):
|
||||
classified |= ha_api_drift.entity_class_names(
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
)
|
||||
covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX}
|
||||
|
||||
uncovered = classified - covered - _INTERMEDIATE_BASES
|
||||
|
||||
assert uncovered == set(), (
|
||||
f"entity classes never exercised against real HA: {sorted(uncovered)} — "
|
||||
f"add them to _REAL_HA_MATRIX"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""tests/test_harness_scoping.py — the stub harness must not depend on file order (t44).
|
||||
|
||||
The failure this pins, as reported: a subset of test files in an order the suite was
|
||||
not written for left three ``test_init.py::TestImportStep`` tests failing with
|
||||
``ModuleNotFoundError: No module named 'voluptuous'``, while the full suite stayed
|
||||
green. Two mechanisms produced it, and both are order-only:
|
||||
|
||||
1. ``entity_stublib.install()`` trusted a single sentinel
|
||||
(``homeassistant.components.media_player``). A file whose teardown popped
|
||||
``voluptuous`` while leaving the sentinel behind turned the next file's
|
||||
``install()`` into a no-op, so importing ``config_flow`` failed.
|
||||
2. A file's teardown restored only the names it had listed. A module it loaded
|
||||
(``nsh.diagnostics``, ``nsh.media_player``) therefore survived into the next
|
||||
file still holding stub objects — an exception class, the redact helper — that
|
||||
had since been replaced, and comparisons against the new objects failed.
|
||||
|
||||
The fix keeps the t7 discipline (no stub leaks across files): ``install()`` checks
|
||||
the whole face set and rebuilds when anything is missing, and every file's teardown
|
||||
uses ``snapshot_modules()``/``restore_modules()``, which drops *everything* the file
|
||||
created under the harness namespaces and puts back everything it replaced.
|
||||
|
||||
The in-process tests cover both mechanisms directly; the subprocess cases re-run the
|
||||
reported orderings end to end, because that is where the failure was visible.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
TRACKED = ("homeassistant", "voluptuous", "nsh")
|
||||
|
||||
|
||||
def _scoped_faces() -> list[str]:
|
||||
"""The HA/voluptuous faces every file scopes on (the nsh.* names are modules)."""
|
||||
return [name for name in entity_stublib.ENTITIES_STUB_NAMES if not name.startswith("nsh.")]
|
||||
|
||||
|
||||
def _run(*paths: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
*paths,
|
||||
"-q",
|
||||
"--no-header",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
class TestStubFaceGuard:
|
||||
"""Mechanism 1: a partially popped face set must be repaired, not trusted."""
|
||||
|
||||
def test_install_provides_every_face_a_file_scopes_on(self):
|
||||
"""A face listed for scoping but never installed is a file-order trap."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
|
||||
missing = [name for name in _scoped_faces() if name not in sys.modules]
|
||||
|
||||
assert missing == []
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_complete_face_set_is_left_alone(self):
|
||||
"""The guard must still be an idempotence guard, not a rebuild."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
before = {name: sys.modules[name] for name in _scoped_faces()}
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
assert {name: sys.modules[name] for name in _scoped_faces()} == before
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_sentinel_left_behind_does_not_hide_a_missing_face(self):
|
||||
"""The reported poisoning: media_player present, voluptuous gone."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
kept = sys.modules["homeassistant.components.media_player"]
|
||||
del sys.modules["voluptuous"]
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
assert "voluptuous" in sys.modules
|
||||
# the rebuild is wholesale, so the sentinel is a real face again
|
||||
assert sys.modules["homeassistant.components.media_player"] is not None
|
||||
assert kept is not None
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
class TestTeardownLeaks:
|
||||
"""Mechanism 2: nothing a file created or replaced may survive its teardown."""
|
||||
|
||||
def test_teardown_removes_the_modules_a_file_loaded(self):
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
entity_stublib.load_entity_modules()
|
||||
created = [
|
||||
name
|
||||
for name in sys.modules
|
||||
if name.startswith("nsh.") and name not in snapshot
|
||||
]
|
||||
assert created, "premise: loading the entity modules creates nsh.* modules"
|
||||
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
assert [name for name in sys.modules if name.startswith("nsh.")] == [
|
||||
name for name in snapshot if name.startswith("nsh.")
|
||||
]
|
||||
assert {
|
||||
name: sys.modules.get(name)
|
||||
for name in snapshot
|
||||
if name.split(".", 1)[0] in TRACKED
|
||||
} == snapshot
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
def test_a_reloaded_module_holds_the_current_stub_objects(self):
|
||||
"""The stale-object failure, exactly: diagnostics vs the redact helper."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
try:
|
||||
entity_stublib.install()
|
||||
stale_module = entity_stublib.load_entity_modules()["diagnostics"]
|
||||
stale = stale_module.async_redact_data
|
||||
entity_stublib.restore_modules(snapshot) # ...the file ends
|
||||
|
||||
# ...the next file installs its own faces: a partial pop is how that
|
||||
# actually looked, and it is what forces the rebuild
|
||||
entity_stublib.install()
|
||||
del sys.modules["homeassistant.helpers.redact"]
|
||||
entity_stublib.install()
|
||||
fresh_modules = entity_stublib.load_entity_modules()
|
||||
|
||||
current = sys.modules["homeassistant.helpers.redact"].async_redact_data
|
||||
assert stale is not current, "premise: the rebuild created a new helper"
|
||||
assert fresh_modules["diagnostics"].async_redact_data is current
|
||||
# ...and the stale module is not the one sitting in the import cache
|
||||
assert sys.modules["nsh.diagnostics"] is fresh_modules["diagnostics"]
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def test_every_file_that_loads_the_harness_scopes_it():
|
||||
"""A missing teardown is invisible until the NEXT file runs — so check for it.
|
||||
|
||||
This is the mistake the fix itself invites: a new test file calls
|
||||
load_entity_modules() for convenience and forgets the per-file scope, and the
|
||||
failure surfaces somewhere else entirely (t44 added exactly that file).
|
||||
"""
|
||||
#: Files that exercise the harness in a CHILD process: they leave nothing in
|
||||
#: this process's sys.modules, so they need no teardown.
|
||||
child_process_only = {"test_module_coverage.py"}
|
||||
offenders = []
|
||||
for path in sorted((REPO_ROOT / "tests").glob("test_*.py")):
|
||||
if path.name in child_process_only:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
loads = "load_entity_modules(" in text or "entity_stublib.install()" in text
|
||||
if loads and "snapshot_modules()" not in text:
|
||||
offenders.append(path.name)
|
||||
|
||||
assert offenders == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"paths",
|
||||
[
|
||||
# the reported reproduction, cut to the two files that trigger it
|
||||
("tests/test_standalone_setup.py", "tests/test_init.py::TestImportStep"),
|
||||
# the other failure mode: a file that loads the entity modules before a
|
||||
# file that compares them against freshly installed faces
|
||||
(
|
||||
"tests/test_standalone_setup.py",
|
||||
"tests/test_config_flow_protocol.py",
|
||||
"tests/test_init.py",
|
||||
"tests/test_command_routing.py::TestAdbCommandService",
|
||||
"tests/test_command_routing.py::TestFaithfulHaStubs",
|
||||
"tests/test_command_routing.py::TestServiceRegistryKey",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_arbitrary_file_orders_pass(paths):
|
||||
"""Order is the thing under test, so it is executed, not reasoned about."""
|
||||
result = _run(*paths)
|
||||
|
||||
assert result.returncode == 0, result.stdout[-3000:]
|
||||
+37
-14
@@ -39,19 +39,14 @@ _INIT_STUB_NAMES = (
|
||||
@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}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
if "nsh.init" not in sys.modules:
|
||||
import conftest
|
||||
|
||||
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
|
||||
yield sys.modules["nsh.init"]
|
||||
for name in _INIT_STUB_NAMES:
|
||||
original = snapshot[name]
|
||||
if original is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = original
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
class _SeedEntriesStub:
|
||||
@@ -119,8 +114,9 @@ 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
|
||||
from nsh.const import CONF_ADB_PORT, CONF_HOST, DEFAULT_ADB_PORT
|
||||
|
||||
# t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
[{"name": "Living", "adb_host": "10.0.0.31"}]
|
||||
)
|
||||
@@ -128,20 +124,31 @@ class TestYamlSeedBlocks:
|
||||
assert len(blocks) == 1
|
||||
seed = blocks[0]
|
||||
assert seed["name"] == "Living"
|
||||
assert seed[CONF_ADB_HOST] == "10.0.0.31"
|
||||
assert seed[CONF_HOST] == "10.0.0.31"
|
||||
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
|
||||
assert seed["adb_enabled"] is True
|
||||
assert "unique_id" not in seed # optional key only
|
||||
|
||||
def test_the_current_host_key_seeds_too(self, _integration_module):
|
||||
from nsh.const import CONF_HOST
|
||||
|
||||
seed = _integration_module._yaml_seed_blocks(
|
||||
[{"name": "Desk", CONF_HOST: "shield.local"}]
|
||||
)[0]
|
||||
|
||||
assert seed[CONF_HOST] == "shield.local"
|
||||
assert seed["adb_enabled"] is True # an address names the Shield
|
||||
|
||||
def test_single_block_and_protocol_hint(self, _integration_module):
|
||||
blocks = _integration_module._yaml_seed_blocks(
|
||||
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
|
||||
)
|
||||
|
||||
assert len(blocks) == 1
|
||||
# protocol fields are prefill hints ONLY (pairing stays interactive)
|
||||
assert blocks[0]["protocol_host"] == "shield.local"
|
||||
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
|
||||
assert blocks[0]["host"] == "shield.local"
|
||||
assert blocks[0]["adb_port"] == 5000
|
||||
assert blocks[0]["adb_enabled"] is False
|
||||
|
||||
def test_list_and_single_block_shapes(self, _integration_module):
|
||||
single = _integration_module._yaml_seed_blocks({"name": "A"})
|
||||
@@ -166,7 +173,23 @@ class TestSeeding:
|
||||
domain, context, data = hass.config_entries.flows[0]
|
||||
assert domain == "nvidia_shield"
|
||||
assert context["source"] == "import"
|
||||
assert data["adb_host"] == "10.0.0.5"
|
||||
assert data["host"] == "10.0.0.5" # t41: one address field
|
||||
|
||||
|
||||
class TestSeedingCurrentKey:
|
||||
"""The t41 `host` key seeds exactly like the legacy one."""
|
||||
|
||||
async def test_host_key_seeds_a_flow(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
|
||||
await _integration_module.async_setup(
|
||||
hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]}
|
||||
)
|
||||
if hass.scheduled_tasks:
|
||||
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
|
||||
|
||||
assert len(hass.config_entries.flows) == 1
|
||||
assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7"
|
||||
|
||||
async def test_hostless_block_never_seeds(self, _integration_module):
|
||||
hass = _SeedHass(entries=[])
|
||||
@@ -229,12 +252,12 @@ class TestImportStep:
|
||||
return flow
|
||||
|
||||
async def test_ui_configured_host_never_re_seeded(self):
|
||||
from nsh.const import CONF_ADB_HOST
|
||||
from nsh.const import CONF_HOST
|
||||
|
||||
existing = SimpleNamespace(
|
||||
entry_id="ui1",
|
||||
unique_id=None,
|
||||
options={CONF_ADB_HOST: "10.0.0.31"},
|
||||
options={CONF_HOST: "10.0.0.31"},
|
||||
)
|
||||
# options live on the entry (conf zeroconf guard reads them)
|
||||
flow = self._flow([existing])
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"""tests/test_media_sources.py — source_list, source and native select_source (t47).
|
||||
|
||||
The last piece a universal-media-player replacement needs from this integration:
|
||||
a dropdown of the apps (``source_list``), an answer to "what is on screen"
|
||||
(``source``), and a way to pick one (``media_player.select_source``). All three read
|
||||
the SAME app directory — the user's Apps map, else the curated default — so the
|
||||
dropdown cannot offer a name the launch path cannot resolve.
|
||||
|
||||
The t44 rule still holds: a configured ``select_source`` script takes precedence,
|
||||
because selecting a source can be more than launching an app. Without one, the name
|
||||
resolves back to its package and goes out over the shared ``play_media("app", …)``
|
||||
path; an unknown name is a translated error, never a silent no-op.
|
||||
"""
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import CONF_APP_NAME_MAP, CONF_COMMAND_SCRIPTS, SCRIPT_VAR_APP_NAME
|
||||
from test_command_routing import make_player
|
||||
|
||||
TIVIMATE = "ar.tvplayer.tv"
|
||||
SCRIPT = "script.shield_source"
|
||||
|
||||
|
||||
@pytest.fixture(name="modules", autouse=True)
|
||||
def scoped_modules():
|
||||
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
try:
|
||||
yield entity_stublib.load_entity_modules()
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _player(modules, *, app_map=None, scripts=None, foreground=None):
|
||||
player, served, _intents = make_player(modules)
|
||||
if app_map is not None:
|
||||
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||
if scripts is not None:
|
||||
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
|
||||
if foreground is not None:
|
||||
player._manager._on_current_app(foreground)
|
||||
return player, served
|
||||
|
||||
|
||||
class TestSourceList:
|
||||
"""The dropdown: friendly names, in a stable order, from the one directory."""
|
||||
|
||||
def test_it_comes_from_the_apps_map(self, modules):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={"com.custom.one": "One", "com.custom.two": "Two"},
|
||||
)
|
||||
|
||||
assert player.source_list == ["One", "Two"] # map order, not shuffled
|
||||
|
||||
def test_it_falls_back_to_the_curated_directory(self, modules):
|
||||
player, _served = _player(modules) # no map configured
|
||||
|
||||
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
|
||||
|
||||
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
|
||||
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
|
||||
|
||||
An empty entry is worst than useless: it is a dropdown item that
|
||||
select_source has to reject as an unknown source.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={
|
||||
"com.blank": "",
|
||||
TIVIMATE: "TiviMate",
|
||||
"com.spaces": " ",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
},
|
||||
)
|
||||
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert "" not in player.source_list
|
||||
|
||||
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
|
||||
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
|
||||
for app_map in (
|
||||
None, # the curated default
|
||||
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player.source_list == [child.title for child in root.children]
|
||||
|
||||
async def test_the_survivors_still_resolve_and_launch(self, modules):
|
||||
"""The filter must not cost a resolvable name (t49)."""
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={"com.blank": "", TIVIMATE: "TiviMate", "com.blank2": " "},
|
||||
)
|
||||
|
||||
for name in player.source_list:
|
||||
assert player._source_package(name) is not None
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
# the blank entries are not selectable, and never launch
|
||||
assert player._source_package("") is None
|
||||
assert player._source_package(" ") is None
|
||||
|
||||
def test_every_offered_name_resolves_to_a_package(self, modules):
|
||||
"""The invariant the shared directory buys: no name without a destination.
|
||||
|
||||
t51 extends it to the hand-edited shapes: blanks, padded names and two
|
||||
entries normalizing to one name all still leave only resolvable offers.
|
||||
"""
|
||||
for app_map in (
|
||||
None,
|
||||
{"com.custom.one": "One"},
|
||||
{
|
||||
"com.blank": "",
|
||||
"com.blank.package": " ",
|
||||
TIVIMATE: " TiviMate ",
|
||||
"com.a": "Netflix",
|
||||
"com.b": " Netflix ",
|
||||
"": "Ghost",
|
||||
},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
|
||||
for name in player.source_list:
|
||||
assert player._source_package(name) is not None, name
|
||||
|
||||
|
||||
class TestSource:
|
||||
"""`source` is the foreground app's resolved name — the app_name chain."""
|
||||
|
||||
def test_it_resolves_through_the_apps_map(self, modules):
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate"}, foreground=TIVIMATE
|
||||
)
|
||||
|
||||
assert player.source == "TiviMate"
|
||||
assert player.source == player.app_name # one truth, not two
|
||||
|
||||
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
|
||||
player, _served = _player(modules, foreground="com.netflix.ninja")
|
||||
player._manager._app_labels["com.netflix.ninja"] = (
|
||||
"Netflix",
|
||||
player._manager._clock(),
|
||||
)
|
||||
|
||||
assert player.source == "Netflix"
|
||||
|
||||
def test_it_falls_back_to_the_raw_package(self, modules):
|
||||
player, _served = _player(modules, foreground="com.unknown.app")
|
||||
|
||||
assert player.source == "com.unknown.app"
|
||||
|
||||
def test_it_is_unknown_when_nothing_has_reported(self, modules):
|
||||
player, _served = _player(modules)
|
||||
|
||||
assert player.source is None
|
||||
|
||||
|
||||
class TestSelectSource:
|
||||
"""Picking a source: script first, else the native launch, else an error."""
|
||||
|
||||
async def test_a_name_launches_its_package(self, modules):
|
||||
player, served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
assert served == [] # no script was configured, and nothing else left
|
||||
|
||||
async def test_the_curated_names_launch_too(self, modules):
|
||||
player, _served = _player(modules)
|
||||
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.netflix.ninja"]
|
||||
|
||||
async def test_a_raw_package_and_any_case_are_accepted(self, modules):
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
await player.async_select_source(TIVIMATE)
|
||||
await player.async_select_source("tivimate")
|
||||
await player.async_select_source(" NETFLIX ")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [
|
||||
TIVIMATE,
|
||||
TIVIMATE,
|
||||
"com.netflix.ninja",
|
||||
]
|
||||
|
||||
async def test_a_configured_script_still_wins(self, modules):
|
||||
player, served = _player(
|
||||
modules,
|
||||
app_map={TIVIMATE: "TiviMate"},
|
||||
scripts={"select_source": SCRIPT},
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [
|
||||
(
|
||||
"script",
|
||||
"turn_on",
|
||||
{"entity_id": SCRIPT, "variables": {SCRIPT_VAR_APP_NAME: "TiviMate"}},
|
||||
)
|
||||
]
|
||||
assert player._manager._protocol.launch_calls == [] # native path untouched
|
||||
|
||||
async def test_an_unknown_name_raises_a_translated_error(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules, app_map={TIVIMATE: "TiviMate"})
|
||||
|
||||
with pytest.raises(ServiceValidationError) as raised:
|
||||
await player.async_select_source("Not An App")
|
||||
|
||||
assert raised.value.translation_domain == "nvidia_shield"
|
||||
assert raised.value.translation_key == "unknown_source"
|
||||
assert raised.value.translation_placeholders == {"source": "Not An App"}
|
||||
assert player._manager._protocol.launch_calls == [] # nothing was sent
|
||||
|
||||
async def test_an_empty_name_is_an_error_too(self, modules):
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
player, _served = _player(modules)
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await player.async_select_source(" ")
|
||||
|
||||
|
||||
class TestAdvertisedFeatures:
|
||||
"""HA routes a service by its feature flag, so the flag has to be honest."""
|
||||
|
||||
def test_a_source_list_advertises_select_source(self, modules):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules)
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_a_script_advertises_it_even_with_no_directory(self, modules, monkeypatch):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, scripts={"select_source": SCRIPT})
|
||||
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_neither_means_it_is_not_offered(self, modules, monkeypatch):
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules)
|
||||
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
|
||||
|
||||
assert not player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
def test_select_source_is_not_gated_on_the_volume_set_script(self, modules):
|
||||
"""The two script-only commands stay independent."""
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
|
||||
player, _served = _player(modules, scripts={"volume_set": "script.volume"})
|
||||
|
||||
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
|
||||
|
||||
class TestNormalizedDirectories:
|
||||
"""t51: a hand-edited map is normalized ONCE, in the shared accessor.
|
||||
|
||||
The two residuals t49 left share one cause — `_app_directory` handed the raw map
|
||||
out, so the Browse tree showed a child with a blank title and the reverse lookup
|
||||
compared the user's padded name against itself. The policy now lives in that one
|
||||
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
|
||||
name) and is pinned here, together with the promise that a well-formed map is
|
||||
untouched.
|
||||
"""
|
||||
|
||||
BLANKED = {
|
||||
"com.blank": "",
|
||||
TIVIMATE: "TiviMate",
|
||||
"com.spaces": " ",
|
||||
"": "Ghost",
|
||||
}
|
||||
|
||||
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
|
||||
"""The Browse tree and the dropdown are the same, resolved offer."""
|
||||
player, _served = _player(modules, app_map=dict(self.BLANKED))
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.title for child in root.children] == ["TiviMate"]
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_blank_package_is_dropped_too(self, modules):
|
||||
"""An entry with no package is a child that cannot be launched."""
|
||||
player, _served = _player(
|
||||
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# display and match key are one string, everywhere
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
|
||||
# ...so every spelling the user may type finds the package
|
||||
assert player._source_package("TiviMate") == TIVIMATE
|
||||
assert player._source_package(" TiviMate ") == TIVIMATE
|
||||
assert player._source_package("tivimate") == TIVIMATE
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_two_packages_with_one_name_keep_a_deterministic_winner(self, modules):
|
||||
"""Both apps stay reachable; the NAME goes to the first in map order."""
|
||||
player, _served = _player(
|
||||
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# both are still offered in the tree, each by its own package id
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Netflix"),
|
||||
("com.b", "Netflix"),
|
||||
]
|
||||
# the dropdown offers the name once, and resolves to the first entry
|
||||
assert player.source_list == ["Netflix"]
|
||||
assert player._source_package("Netflix") == "com.a"
|
||||
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.a"]
|
||||
|
||||
# map order decides — the user's order, not chance
|
||||
swapped, _served_swapped = _player(
|
||||
modules, app_map={"com.b": "Netflix", "com.a": "Netflix"}
|
||||
)
|
||||
|
||||
assert swapped._source_package("Netflix") == "com.b"
|
||||
|
||||
async def test_a_wellformed_map_passes_through_unchanged(self, modules):
|
||||
"""Stripping touches the EDGES: an inner space is part of the name."""
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
|
||||
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||
|
||||
|
||||
class TestCaseInsensitiveNames:
|
||||
"""t53: one row per NAME, compared case-insensitively — as the lookup does.
|
||||
|
||||
The dropdown and the reverse lookup disagreed about case: two entries called
|
||||
`Dup` and `dup` survived an exact-match dedupe as two rows, while
|
||||
`_source_package` — case-insensitive by design (t47) — resolved BOTH to the
|
||||
first entry. So the second row launched the first entry's app: a row that lies
|
||||
about what it does, and the one shape here the editor can produce by itself.
|
||||
"""
|
||||
|
||||
async def test_names_differing_only_by_case_are_offered_once(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
# one row, keeping the first spelling the user typed
|
||||
assert player.source_list == ["Dup"]
|
||||
|
||||
# ...and the Browse tree still holds both apps, each under its own id
|
||||
root = await player.async_browse_media()
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Dup"),
|
||||
("com.b", "dup"),
|
||||
]
|
||||
|
||||
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
await player.async_select_source(player.source_list[0])
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.a"] # the row it shows
|
||||
|
||||
async def test_the_shadowed_app_stays_reachable_by_id(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
assert player._source_package("com.b") == "com.b"
|
||||
|
||||
await player.async_select_source("com.b")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||
|
||||
# the shadowed NAME still resolves to the one the list offers (first wins)
|
||||
assert player._source_package("dup") == "com.a"
|
||||
|
||||
async def test_the_first_spelling_wins_in_map_order(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.b": "dup", "com.a": "Dup"})
|
||||
|
||||
assert player.source_list == ["dup"]
|
||||
|
||||
await player.async_select_source("dup")
|
||||
|
||||
assert player._manager._protocol.launch_calls == ["com.b"]
|
||||
|
||||
async def test_a_mixed_map_offers_each_name_once(self, modules):
|
||||
player, _served = _player(
|
||||
modules,
|
||||
app_map={
|
||||
"com.a": "Dup",
|
||||
"com.b": "dup",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
"com.google.android.youtube.tv": "netflix",
|
||||
TIVIMATE: "TiviMate Pro",
|
||||
},
|
||||
)
|
||||
|
||||
assert player.source_list == ["Dup", "Netflix", "TiviMate Pro"]
|
||||
assert player._source_package("Netflix") == "com.netflix.ninja"
|
||||
|
||||
def test_the_offered_list_never_repeats_a_name(self, modules):
|
||||
"""The invariant behind the fix, over every map shape in this file."""
|
||||
for app_map in (
|
||||
None,
|
||||
{"com.a": "Dup", "com.b": "dup"},
|
||||
{"com.a": "Dup", "com.b": "DUP", "com.c": "dup"},
|
||||
{"com.blank": "", TIVIMATE: " TiviMate ", "com.a": "Netflix", "com.b": "netflix"},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
folded = [name.casefold() for name in player.source_list]
|
||||
|
||||
assert len(folded) == len(set(folded)), app_map
|
||||
assert len(folded) == len(player.source_list)
|
||||
|
||||
def test_a_padded_map_value_is_displayed_verbatim_by_app_name(self, modules):
|
||||
"""The README's boundary, pinned: the list cleans it, app_name does not.
|
||||
|
||||
The name map is the user's own override, so it is used as typed — which is
|
||||
why `source` can be a string that is not in source_list.
|
||||
"""
|
||||
player, _served = _player(
|
||||
modules, app_map={TIVIMATE: " TiviMate "}, foreground=TIVIMATE
|
||||
)
|
||||
|
||||
assert player.source_list == ["TiviMate"] # the dropdown cleans it
|
||||
assert player.source == " TiviMate " # this surface shows it as typed
|
||||
assert player.source not in player.source_list
|
||||
|
||||
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
|
||||
"""Blanks are not displayed as nothing — the chain continues past them."""
|
||||
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
|
||||
|
||||
assert player.source == TIVIMATE
|
||||
@@ -0,0 +1,204 @@
|
||||
"""tests/test_module_coverage.py — nothing in the package may dangle (t41).
|
||||
|
||||
Two defects motivated this file, and both shipped with a green suite:
|
||||
|
||||
1. ``button.py`` imported a helper that had been deleted. No harness ever loaded
|
||||
that file (``entity_stublib.load_entity_modules`` lists the platforms it
|
||||
knows), so the ImportError would have appeared only in a user's log. The fix
|
||||
is exhaustive by construction: the sweep walks the package for ``*.py`` files
|
||||
in a clean interpreter, under the stub harness, and loads every one of them.
|
||||
A new platform is covered the moment the file exists — there is no list to
|
||||
update and no special case.
|
||||
|
||||
2. The drift sweep resolved ``homeassistant.*`` imports only, so it could not
|
||||
see a relative import of a name that no longer exists — the same defect one
|
||||
layer up. ``ha_api_drift.scan_package_imports`` now resolves the package's own
|
||||
imports from the AST, with no Home Assistant and therefore no stub needed.
|
||||
|
||||
Each sweep carries its own red proof, and nothing about the pre-t41 tree supplies
|
||||
it. At ``c588a7c`` ``helpers.py`` still DEFINED ``discover_adb_host``, so there was
|
||||
no broken relative import to find; and that commit's engine predates BOTH passes
|
||||
(its ``ha_api_drift.py`` has no entity-contract check and no package-import check
|
||||
at all), so a real-HA run there is simply clean — exit 0, "no Home-Assistant API
|
||||
drift (13 modules …)", no findings. The proof is therefore built, never borrowed:
|
||||
a copy of the tree with the ``discover_adb_host`` reference actually reverted must
|
||||
fail each sweep, or the check is decoration.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import ha_api_drift
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = _SHIELD_DIR.parent.parent
|
||||
|
||||
#: Loads every module of a package under the stub harness, in a clean process.
|
||||
#: ``argv``: 1 = this tests directory, 2 = the package directory to sweep.
|
||||
_DRIVER = r"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
tests_dir, package_root = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
sys.path.insert(0, str(tests_dir))
|
||||
sys.path.insert(0, str(package_root))
|
||||
|
||||
import entity_stublib # noqa: E402
|
||||
from conftest import _load_module # noqa: E402
|
||||
|
||||
# conftest eagerly loads a few stdlib-only modules from the REAL tree (it cannot
|
||||
# know about a copied one): point the synthetic package at the tree under test
|
||||
# and drop what it imported, so every relative import resolves inside the copy.
|
||||
sys.modules["nsh"].__path__ = [str(package_root)]
|
||||
for name in [n for n in list(sys.modules) if n.startswith("nsh.")]:
|
||||
del sys.modules[name]
|
||||
|
||||
entity_stublib.install()
|
||||
|
||||
loaded, failures = [], []
|
||||
for path in sorted(package_root.rglob("*.py")):
|
||||
if "__pycache__" in path.parts:
|
||||
continue
|
||||
rel = path.relative_to(package_root)
|
||||
if rel.name == "__init__.py":
|
||||
dotted = ".".join(rel.parent.parts)
|
||||
name = "nsh.init" if dotted in ("", ".") else f"nsh.{dotted}"
|
||||
else:
|
||||
name = "nsh." + ".".join(rel.with_suffix("").parts)
|
||||
search = [str(path.parent)] if rel.name == "__init__.py" else None
|
||||
try:
|
||||
_load_module(name, path, search)
|
||||
except Exception as exc: # noqa: BLE001 - the error IS the finding
|
||||
failures.append(f"{rel}: {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
loaded.append(str(rel))
|
||||
|
||||
for rel in loaded:
|
||||
print(f"OK {rel}")
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}")
|
||||
print(f"SUMMARY loaded={len(loaded)} failed={len(failures)} total={len(loaded) + len(failures)}")
|
||||
sys.exit(1 if failures else 0)
|
||||
"""
|
||||
|
||||
|
||||
def _package_modules(root: Path) -> set[str]:
|
||||
"""Every Python file of a package, as the sweep reports them."""
|
||||
return {
|
||||
str(path.relative_to(root))
|
||||
for path in root.rglob("*.py")
|
||||
if "__pycache__" not in path.parts
|
||||
}
|
||||
|
||||
|
||||
def _sweep(root: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", _DRIVER, str(TESTS_DIR), str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _reverted_copy(tmp_path: Path) -> Path:
|
||||
"""A copy of the package where button.py imports the deleted helper again."""
|
||||
target = tmp_path / "nvidia_shield"
|
||||
shutil.copytree(
|
||||
_SHIELD_DIR,
|
||||
target,
|
||||
ignore=shutil.ignore_patterns("__pycache__", "*.json", "*.png"),
|
||||
)
|
||||
button = target / "button.py"
|
||||
button.write_text(
|
||||
"from .helpers import discover_adb_host # the reverted reference\n"
|
||||
+ button.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def test_every_module_imports_under_the_stub_harness():
|
||||
"""The whole package loads in a clean interpreter — no module is skipped."""
|
||||
result = _sweep(_SHIELD_DIR)
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
assert result.returncode == 0, output
|
||||
reported = {
|
||||
line.split(" ", 1)[1]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.startswith(("OK ", "FAIL "))
|
||||
}
|
||||
assert reported == _package_modules(_SHIELD_DIR), "the sweep skipped or invented a file"
|
||||
assert "failed=0" in result.stdout, output
|
||||
|
||||
|
||||
def test_the_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||
"""Red proof 1: the exact defect that motivated this check.
|
||||
|
||||
button.py imported ``discover_adb_host`` from helpers after that helper was
|
||||
deleted (t41 removed it with the entity path). The sweep must fail loudly.
|
||||
"""
|
||||
result = _sweep(_reverted_copy(tmp_path))
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
assert result.returncode != 0, output
|
||||
assert "discover_adb_host" in output
|
||||
assert "FAIL button.py" in result.stdout
|
||||
|
||||
|
||||
def test_every_relative_import_resolves():
|
||||
"""The same contract, statically: the package's own imports all resolve."""
|
||||
findings = ha_api_drift.scan_package_imports()
|
||||
|
||||
assert findings == [], "\n".join(str(finding) for finding in findings)
|
||||
|
||||
|
||||
def test_the_relative_import_pass_is_not_vacuous():
|
||||
"""A pass that resolved nothing would be green for the wrong reason."""
|
||||
report = ha_api_drift.Report()
|
||||
|
||||
ha_api_drift._scan_package_imports([_SHIELD_DIR], report)
|
||||
|
||||
assert report.relative_imports >= ha_api_drift.FLOORS["relative_imports"]
|
||||
# and the walk itself reached every module, not only the top level (the
|
||||
# floor sits above the top-level-only count, so a shallow glob trips it)
|
||||
assert len(_package_modules(_SHIELD_DIR)) >= ha_api_drift.FLOORS["modules"]
|
||||
|
||||
|
||||
def test_the_static_sweep_fails_on_a_reverted_helper_reference(tmp_path):
|
||||
"""Red proof 2: the AST pass sees the same defect, with no Home Assistant."""
|
||||
findings = ha_api_drift.scan_package_imports(_reverted_copy(tmp_path))
|
||||
|
||||
assert [finding.kind for finding in findings] == ["broken-relative-import"]
|
||||
assert "discover_adb_host" in findings[0].detail
|
||||
assert findings[0].path.endswith("button.py")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
# a name nobody defines
|
||||
("from .helpers import no_such_helper\n", "broken-relative-import"),
|
||||
# a relative import that leaves the package
|
||||
("from ..sibling import thing\n", "broken-relative-import"),
|
||||
# a submodule import is valid without a binding in __init__
|
||||
("from .ha_adb_common import adb_client\n", None),
|
||||
],
|
||||
)
|
||||
def test_the_pass_judges_the_cases_that_matter(tmp_path, source, expected):
|
||||
package = tmp_path / "nvidia_shield"
|
||||
(package / "ha_adb_common").mkdir(parents=True)
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "helpers.py").write_text("def entry_host(entry):\n return ''\n", encoding="utf-8")
|
||||
(package / "ha_adb_common" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "ha_adb_common" / "adb_client.py").write_text("", encoding="utf-8")
|
||||
(package / "module.py").write_text(source, encoding="utf-8")
|
||||
|
||||
findings = ha_api_drift.scan_package_imports(package)
|
||||
|
||||
assert [finding.kind for finding in findings] == ([expected] if expected else [])
|
||||
@@ -0,0 +1,63 @@
|
||||
"""tests/test_readme.py — the README's user-facing contracts (t41).
|
||||
|
||||
Documentation drifts silently: the seed example is read by people who then
|
||||
report "the key did nothing". These pins are deliberately textual — no YAML
|
||||
parser dependency — but they check the two things that actually hurt:
|
||||
|
||||
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
|
||||
honours (nothing documented that the integration ignores);
|
||||
* the replacement guide names the integrations and the glue it retires, so the
|
||||
t41 ruling stays visible to users, not only in the changelog.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
|
||||
# seed block reaches the entry (the rest is the interactive flow's or the
|
||||
# options page's to set).
|
||||
SEEDED_KEYS = {
|
||||
"name",
|
||||
"host",
|
||||
"adb_port",
|
||||
"adb_enabled",
|
||||
"protocol_host", # legacy, still accepted
|
||||
"adb_host", # legacy, still accepted
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def readme_text() -> str:
|
||||
return README.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _seed_example(text: str) -> str:
|
||||
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
|
||||
assert match, "the README has no `nvidia_shield:` example block"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_the_example_block_only_uses_seeded_keys(readme_text):
|
||||
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
|
||||
|
||||
assert keys, "the example block declares no keys"
|
||||
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
|
||||
|
||||
|
||||
def test_the_example_block_documents_the_one_address_field(readme_text):
|
||||
example = _seed_example(readme_text)
|
||||
|
||||
assert "host:" in example
|
||||
# the legacy spellings are still explained, as back-compat
|
||||
assert "protocol_host" in readme_text and "adb_host" in readme_text
|
||||
|
||||
|
||||
def test_the_replacement_guide_retires_the_old_stack(readme_text):
|
||||
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
|
||||
assert needle in readme_text, needle
|
||||
# and it says the UI is the primary path
|
||||
assert "primary path" in readme_text
|
||||
@@ -0,0 +1,654 @@
|
||||
"""tests/test_release_versioning.py — the release version decision (t42).
|
||||
|
||||
Why this file exists: the version the workflow picks decides whether HACS ever
|
||||
offers a user the update. Every wrong answer is silent — the tag and the release
|
||||
still appear, only the manifest never moves — and the real evidence is in the
|
||||
repo history: v2026.10.09 was published while master's manifest still read
|
||||
2026.10.08. The decision therefore lives in ``scripts/determine-release-version.sh``
|
||||
and every branch below is driven directly, rather than trusted to a CI run.
|
||||
|
||||
The four scenarios the task names, plus the boundary cases that made the old
|
||||
inline shell wrong:
|
||||
|
||||
(a) manifest version untagged -> manifest wins (BUMP=no)
|
||||
(b) manifest tagged elsewhere, date free -> date
|
||||
(c) manifest AND date tagged -> same-day CALVER suffix, lowest free N
|
||||
(d) the commit is already released -> RELEASE=no, explicit no-op signal
|
||||
|
||||
The script is also the only place that reads the clock, and it reads it in UTC.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO_ROOT / "scripts" / "determine-release-version.sh"
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
||||
MANIFEST = REPO_ROOT / "custom_components" / "nvidia_shield" / "manifest.json"
|
||||
|
||||
HEAD = "a" * 40
|
||||
OTHER = "b" * 40
|
||||
TODAY = "2026.10.11"
|
||||
|
||||
|
||||
def _resolve(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
manifest_version: str,
|
||||
tags=(),
|
||||
today: str | None = TODAY,
|
||||
head: str | None = HEAD,
|
||||
env: dict | None = None,
|
||||
):
|
||||
"""Run the resolver against a fixture manifest and tag list."""
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": manifest_version}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tag_list = tmp_path / "tags"
|
||||
tag_list.write_text(
|
||||
"".join(f"{tag} {sha}\n" for tag, sha in tags), encoding="utf-8"
|
||||
)
|
||||
|
||||
argv = [
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--manifest",
|
||||
str(manifest),
|
||||
"--tags-from",
|
||||
str(tag_list),
|
||||
]
|
||||
if today is not None:
|
||||
argv += ["--today", today]
|
||||
if head is not None:
|
||||
argv += ["--head", head]
|
||||
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **(env or {})},
|
||||
check=False,
|
||||
)
|
||||
values = dict(
|
||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||
)
|
||||
return result, values
|
||||
|
||||
|
||||
# ── (a) the manifest wins while its version is untagged ──────────────────────
|
||||
|
||||
|
||||
def test_an_untagged_manifest_version_is_released_as_is(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.11", tags=[("v2026.10.09", OTHER)]
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == "2026.10.11"
|
||||
assert values["TAG"] == "v2026.10.11"
|
||||
assert values["SOURCE"] == "manifest"
|
||||
assert values["RELEASE"] == "yes"
|
||||
# the manifest already says it: no write-back commit, so the tag lands on
|
||||
# the pushed commit itself
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
|
||||
def test_a_leading_v_in_the_manifest_is_tolerated(tmp_path):
|
||||
"""The tag adds the `v`; a hand-written one must not become the version."""
|
||||
_result, values = _resolve(tmp_path, manifest_version="v2026.10.11")
|
||||
|
||||
assert values["VERSION"] == "2026.10.11"
|
||||
assert values["TAG"] == "v2026.10.11"
|
||||
|
||||
|
||||
# ── (b) once the manifest version is tagged, the date takes over ─────────────
|
||||
|
||||
|
||||
def test_a_tagged_manifest_version_falls_back_to_the_utc_date(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER)]
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == TODAY
|
||||
assert values["SOURCE"] == "date"
|
||||
assert values["RELEASE"] == "yes"
|
||||
# the manifest still says the old version: the workflow must write the new
|
||||
# one back, or HACS keeps comparing against 2026.10.09 forever
|
||||
assert values["BUMP"] == "yes"
|
||||
|
||||
|
||||
# ── (c) a second release in one UTC day ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_tagged_date_falls_back_to_a_same_day_suffix(tmp_path):
|
||||
result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)],
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["VERSION"] == f"{TODAY}.2"
|
||||
assert values["SOURCE"] == "same-day"
|
||||
assert values["RELEASE"] == "yes"
|
||||
assert values["BUMP"] == "yes"
|
||||
|
||||
|
||||
def test_the_suffix_is_the_lowest_untagged_n_from_two(tmp_path):
|
||||
"""Not "highest + 1": a gap is filled, so a failed run does not burn a number."""
|
||||
_result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[
|
||||
("v2026.10.09", OTHER),
|
||||
(f"v{TODAY}", OTHER),
|
||||
(f"v{TODAY}.2", OTHER),
|
||||
],
|
||||
)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.3"
|
||||
|
||||
_result, values = _resolve(
|
||||
tmp_path / "second",
|
||||
manifest_version="2026.10.09",
|
||||
tags=[
|
||||
("v2026.10.09", OTHER),
|
||||
(f"v{TODAY}", OTHER),
|
||||
(f"v{TODAY}.2", OTHER),
|
||||
(f"v{TODAY}.3", OTHER),
|
||||
(f"v{TODAY}.5", OTHER), # a gap at .4 must be reused
|
||||
],
|
||||
)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.4"
|
||||
|
||||
|
||||
# ── (d) nothing to do ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_an_already_released_commit_is_an_explicit_no_op(tmp_path):
|
||||
"""A re-run (manual dispatch, a retried job) must not invent a new version."""
|
||||
result, values = _resolve(
|
||||
tmp_path,
|
||||
manifest_version="2026.10.09",
|
||||
tags=[("v2026.10.09", HEAD)], # this very commit is the release
|
||||
head=HEAD,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["RELEASE"] == "no"
|
||||
assert values["VERSION"] == "2026.10.09"
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["BUMP"] == "no"
|
||||
assert "nothing to release" in result.stderr
|
||||
|
||||
|
||||
def test_a_release_is_never_planned_for_a_tag_that_exists(tmp_path):
|
||||
"""The property the backstop defends, over a spread of tag sets.
|
||||
|
||||
RELEASE=yes must always mean "this version is not tagged yet": that is what
|
||||
makes the workflow's tag step safe without re-checking.
|
||||
"""
|
||||
scenarios = [
|
||||
("2026.10.09", []),
|
||||
("2026.10.09", [("v2026.10.09", OTHER)]),
|
||||
("2026.10.09", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||
("2026.10.11", [("v2026.10.08", OTHER)]),
|
||||
(f"{TODAY}.2", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
|
||||
("2026.10.09", [("v2026.10.09", HEAD)]), # already released
|
||||
]
|
||||
|
||||
for index, (manifest_version, tags) in enumerate(scenarios):
|
||||
_result, values = _resolve(
|
||||
tmp_path / f"case{index}", manifest_version=manifest_version, tags=tags
|
||||
)
|
||||
tagged = {tag for tag, _sha in tags}
|
||||
|
||||
if values["RELEASE"] == "yes":
|
||||
assert values["TAG"] not in tagged, (manifest_version, tags, values)
|
||||
else:
|
||||
assert values["SOURCE"] in {"released", "tagged"}
|
||||
|
||||
|
||||
# ── (e) UTC determinism ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_date_is_utc_whatever_the_runner_timezone_is(tmp_path):
|
||||
"""A version number must not depend on where the job happened to run."""
|
||||
tags = [("v2026.09.30", OTHER)] # the manifest version is tagged -> date
|
||||
runs = []
|
||||
for timezone in ("Pacific/Kiritimati", "Etc/GMT+12", "America/Anchorage"):
|
||||
_result, values = _resolve(
|
||||
tmp_path / timezone.replace("/", "_"),
|
||||
manifest_version="2026.09.30",
|
||||
tags=tags,
|
||||
today=None, # exercise the real clock
|
||||
env={"TZ": timezone},
|
||||
)
|
||||
runs.append(values["VERSION"])
|
||||
|
||||
utc_today = subprocess.run(
|
||||
["date", "-u", "+%Y.%m.%d"], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
assert len(set(runs)) == 1, runs
|
||||
assert runs[0] == utc_today
|
||||
|
||||
|
||||
# ── usage and error handling ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_missing_manifest_fails_loudly(tmp_path):
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--manifest", str(tmp_path / "nope.json"), "--today", TODAY],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "manifest not found" in result.stderr
|
||||
|
||||
|
||||
def test_a_non_calver_manifest_version_fails_loudly(tmp_path):
|
||||
result, _values = _resolve(tmp_path, manifest_version="1.2.3")
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "not YYYY.MM.DD" in result.stderr
|
||||
|
||||
|
||||
def test_an_unknown_argument_is_a_usage_error(tmp_path):
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--nonsense"], capture_output=True, text=True, check=False
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "unknown argument" in result.stderr
|
||||
|
||||
|
||||
def test_github_output_receives_the_same_values(tmp_path):
|
||||
"""The workflow consumes $GITHUB_OUTPUT, so the script must fill it too."""
|
||||
output = tmp_path / "github_output"
|
||||
output.write_text("", encoding="utf-8")
|
||||
|
||||
result, values = _resolve(
|
||||
tmp_path, manifest_version="2026.10.11", env={"GITHUB_OUTPUT": str(output)}
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
written = dict(
|
||||
line.split("=", 1)
|
||||
for line in output.read_text(encoding="utf-8").splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
assert written == values
|
||||
assert set(written) == {"VERSION", "TAG", "SOURCE", "BUMP", "RELEASE"}
|
||||
|
||||
|
||||
# ── the workflow contract (textual: the fast suite has no YAML parser) ───────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def workflow_text() -> str:
|
||||
return WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_the_workflow_uses_the_tested_script(workflow_text):
|
||||
assert "scripts/determine-release-version.sh" in workflow_text
|
||||
|
||||
|
||||
def test_the_workflow_writes_the_manifest_back_and_tags_that_commit(workflow_text):
|
||||
# the exact commit message the task pins, with the skip-ci marker
|
||||
assert "chore(release): v${VERSION} [skip ci]" in workflow_text
|
||||
# the push happens before the tag, so the tag names the bumped commit
|
||||
assert "git push origin HEAD:master" in workflow_text
|
||||
assert 'git push origin "refs/tags/${TAG}"' in workflow_text
|
||||
|
||||
|
||||
def test_a_rejected_bump_push_fails_with_a_manual_instruction(workflow_text):
|
||||
assert "could not push" in workflow_text
|
||||
assert "bump" in workflow_text.lower() and "manually" in workflow_text.lower()
|
||||
|
||||
|
||||
def test_the_workflow_verifies_the_pushed_manifest_before_publishing(workflow_text):
|
||||
assert "origin/master:${MANIFEST}" in workflow_text
|
||||
assert "inconsistent" in workflow_text.lower()
|
||||
|
||||
|
||||
def test_the_bot_commit_cannot_trigger_another_release(workflow_text):
|
||||
# two layers: the conventional skip-ci marker on the commit, and a job-level
|
||||
# guard that re-checks the message (the guard is what must hold even if a
|
||||
# future Gitea stops honouring the marker)
|
||||
assert "[skip ci]" in workflow_text
|
||||
assert "github.event.head_commit.message" in workflow_text
|
||||
assert "workflow_dispatch" in workflow_text
|
||||
|
||||
|
||||
def test_the_skip_guard_matches_the_subject_not_the_whole_message(workflow_text):
|
||||
"""t44 R3: `contains(message, '[skip ci]')` skipped a release silently.
|
||||
|
||||
A developer commit whose BODY mentions the marker is not a release commit, so
|
||||
the guard is anchored to the release bot's subject prefix instead.
|
||||
"""
|
||||
assert "!startsWith(github.event.head_commit.message || '', 'chore(release):')" in (
|
||||
workflow_text
|
||||
)
|
||||
assert "!contains(github.event.head_commit.message" not in workflow_text
|
||||
|
||||
|
||||
def test_the_release_notes_exclude_the_write_back_commit(workflow_text):
|
||||
"""t44 R1: with a write-back the tag IS the bot commit, so notes must skip it."""
|
||||
assert 'if [ "$BUMP" = "yes" ]; then' in workflow_text
|
||||
assert 'NOTES_END="${TAG}^"' in workflow_text
|
||||
assert 'RANGE="${PREV}..${NOTES_END}"' in workflow_text
|
||||
assert 'BUMP: ${{ steps.version.outputs.BUMP }}' in workflow_text
|
||||
|
||||
|
||||
def test_the_preserved_release_behaviour_is_still_there(workflow_text):
|
||||
# one release at a time, never cancelled mid-flight
|
||||
assert "group: release-master" in workflow_text
|
||||
assert "cancel-in-progress: false" in workflow_text
|
||||
# notes from the commit range
|
||||
assert "git log --pretty=format" in workflow_text
|
||||
# the 409-tolerant publish path
|
||||
assert "409 is" in workflow_text or "409" in workflow_text
|
||||
assert "/releases/tags/" in workflow_text
|
||||
# the zip asset
|
||||
assert "attachment=@${ZIP_NAME}" in workflow_text
|
||||
|
||||
|
||||
def test_the_script_reads_the_manifest_the_workflow_bumps():
|
||||
"""One path, so a moved manifest cannot silently release a stale version."""
|
||||
assert MANIFEST.is_file()
|
||||
assert "custom_components/nvidia_shield/manifest.json" in SCRIPT.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
manifest_version = json.loads(MANIFEST.read_text(encoding="utf-8"))["version"]
|
||||
# the script rejects anything else, so a typo here would fail every release
|
||||
assert re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?", manifest_version)
|
||||
|
||||
|
||||
# ── t44 3c: real repositories, and the workflow's own steps by execution ─────
|
||||
|
||||
MANIFEST_REL = "custom_components/nvidia_shield/manifest.json"
|
||||
|
||||
|
||||
def _git(repo, *args) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], cwd=repo, capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
def _repo(tmp_path, *, version="2026.10.09", commits=("a feature commit",)):
|
||||
"""A real repository: the resolver's tag lookups really are git commands."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir(parents=True)
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "dev@example.com")
|
||||
_git(repo, "config", "user.name", "dev")
|
||||
manifest = repo / MANIFEST_REL
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
for index, subject in enumerate(commits):
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{"domain": "nvidia_shield", "version": version, "note": index}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-qm", subject)
|
||||
return repo
|
||||
|
||||
|
||||
def _resolve_in(repo, *, today=TODAY):
|
||||
"""Run the resolver inside a real repo — no tag fixtures, the real git paths."""
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--manifest", MANIFEST_REL, "--today", today],
|
||||
cwd=repo, capture_output=True, text=True, check=False,
|
||||
)
|
||||
values = dict(
|
||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
||||
)
|
||||
return result, values
|
||||
|
||||
|
||||
class TestRealRepositories:
|
||||
"""The tagged-version branches against real git (t44 3c).
|
||||
|
||||
The fixture-based tests above never exercise `refs/tags/X^{commit}`, and that
|
||||
dereference is the whole reason an annotated tag is recognised: for one,
|
||||
`git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a comparison
|
||||
that forgot to dereference would miss a released commit entirely.
|
||||
"""
|
||||
|
||||
def test_an_annotated_tag_on_head_is_already_released(self, tmp_path):
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||
|
||||
result, values = _resolve_in(repo)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["RELEASE"] == "no"
|
||||
assert "nothing to release" in result.stderr
|
||||
# premise: the tag object really is not the commit
|
||||
assert _git(repo, "rev-parse", "v2026.10.09").strip() != _git(
|
||||
repo, "rev-parse", "HEAD"
|
||||
).strip()
|
||||
|
||||
def test_a_lightweight_tag_on_head_is_also_already_released(self, tmp_path):
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "v2026.10.09")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["SOURCE"] == "released"
|
||||
assert values["RELEASE"] == "no"
|
||||
|
||||
def test_a_tag_on_an_older_commit_falls_back_to_the_date(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("the released work", "later work"))
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == TODAY
|
||||
assert values["SOURCE"] == "date"
|
||||
assert values["BUMP"] == "yes"
|
||||
assert values["RELEASE"] == "yes"
|
||||
|
||||
def test_no_tags_at_all_means_the_manifest_wins(self, tmp_path):
|
||||
repo = _repo(tmp_path, version="2026.10.10")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == "2026.10.10"
|
||||
assert values["SOURCE"] == "manifest"
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
def test_the_same_day_scan_walks_real_tags(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("released", "more"))
|
||||
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.2"):
|
||||
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.3"
|
||||
assert values["SOURCE"] == "same-day"
|
||||
|
||||
def test_a_gap_in_the_suffixes_is_reused(self, tmp_path):
|
||||
repo = _repo(tmp_path, commits=("released", "more"))
|
||||
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.3"):
|
||||
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
|
||||
|
||||
_result, values = _resolve_in(repo)
|
||||
|
||||
assert values["VERSION"] == f"{TODAY}.2"
|
||||
|
||||
def test_the_guard_has_a_script_side_layer(self, tmp_path):
|
||||
"""Even with the job guard bypassed, a released commit releases nothing.
|
||||
|
||||
The job-level `if:` cannot be executed here (no Gitea runner), but the
|
||||
decision it protects can: this is the layer that makes a bypass harmless.
|
||||
"""
|
||||
repo = _repo(tmp_path)
|
||||
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
|
||||
# a re-run of the same push, guard or no guard
|
||||
for _attempt in range(2):
|
||||
_result, values = _resolve_in(repo)
|
||||
assert values["RELEASE"] == "no"
|
||||
assert values["BUMP"] == "no"
|
||||
|
||||
|
||||
def _step_script(name: str) -> str:
|
||||
"""The `run:` body of one workflow step, read by hand.
|
||||
|
||||
The fast suite has no YAML parser (the verify command parses the file with
|
||||
pyyaml instead), and executing these steps is worth more than matching their
|
||||
text, so the block is taken verbatim and dedented.
|
||||
"""
|
||||
lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == f"- name: {name}")
|
||||
run_at = next(
|
||||
i for i in range(start, len(lines)) if lines[i].strip() in {"run: |", "run: >-"}
|
||||
)
|
||||
indent = len(lines[run_at + 1]) - len(lines[run_at + 1].lstrip())
|
||||
body = []
|
||||
for line in lines[run_at + 1 :]:
|
||||
if line.strip() and not line.startswith(" " * indent):
|
||||
break
|
||||
body.append(line[indent:] if len(line) >= indent else "")
|
||||
return "\n".join(body)
|
||||
|
||||
|
||||
def _run_step(name: str, cwd, env):
|
||||
return subprocess.run(
|
||||
["bash", "-euo", "pipefail", "-c", _step_script(name)],
|
||||
cwd=cwd, capture_output=True, text=True, env={**os.environ, **env},
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowStepsByExecution:
|
||||
"""Run the workflow's own steps against a bare origin (t44 3c).
|
||||
|
||||
Textual pins cannot tell whether the bump lands on the commit the tag names;
|
||||
these do, and they are what the dry run in the task output was.
|
||||
"""
|
||||
|
||||
VERSION = "2026.10.11"
|
||||
TAG = "v2026.10.11"
|
||||
|
||||
def _clone(self, tmp_path, *, protect=False):
|
||||
origin = tmp_path / "origin.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", "-q", "-b", "master", str(origin)], check=True
|
||||
)
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True)
|
||||
_git(work, "config", "user.email", "dev@example.com")
|
||||
_git(work, "config", "user.name", "dev")
|
||||
manifest = work / MANIFEST_REL
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest.write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": "2026.10.08"}) + "\n"
|
||||
)
|
||||
_git(work, "add", "-A")
|
||||
_git(work, "commit", "-qm", "a feature commit")
|
||||
_git(work, "push", "-q", "origin", "master")
|
||||
if protect:
|
||||
hook = origin / "hooks" / "pre-receive"
|
||||
hook.write_text("#!/bin/sh\necho 'protected branch' >&2\nexit 1\n")
|
||||
hook.chmod(0o755)
|
||||
return work
|
||||
|
||||
def _env(self):
|
||||
return {
|
||||
"MANIFEST": MANIFEST_REL,
|
||||
"VERSION": self.VERSION,
|
||||
"TAG": self.TAG,
|
||||
}
|
||||
|
||||
def test_the_bump_verify_and_tag_steps_agree_on_one_commit(self, tmp_path):
|
||||
work = self._clone(tmp_path)
|
||||
|
||||
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||
assert bump.returncode == 0, bump.stderr
|
||||
assert _git(work, "log", "-1", "--pretty=%s").strip() == (
|
||||
f"chore(release): {self.TAG} [skip ci]"
|
||||
)
|
||||
|
||||
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||
assert verify.returncode == 0, verify.stderr
|
||||
assert f"matching {self.TAG}" in verify.stdout
|
||||
|
||||
tag = _run_step("Create tag", work, self._env())
|
||||
assert tag.returncode == 0, tag.stderr
|
||||
|
||||
head = _git(work, "rev-parse", "HEAD").strip()
|
||||
assert _git(work, "rev-parse", f"{self.TAG}^{{commit}}").strip() == head
|
||||
tagged_manifest = json.loads(_git(work, "show", f"{self.TAG}:{MANIFEST_REL}"))
|
||||
assert tagged_manifest["version"] == self.VERSION
|
||||
assert _git(work, "ls-remote", "--tags", "origin", self.TAG).split()[1] == (
|
||||
f"refs/tags/{self.TAG}"
|
||||
)
|
||||
|
||||
def test_the_verify_step_refuses_an_inconsistent_manifest(self, tmp_path):
|
||||
work = self._clone(tmp_path)
|
||||
_run_step("Write the version back into the manifest", work, self._env())
|
||||
# someone else's release lands on master before this one is verified
|
||||
(work / MANIFEST_REL).write_text(
|
||||
json.dumps({"domain": "nvidia_shield", "version": "2026.10.99"}) + "\n"
|
||||
)
|
||||
_git(work, "add", "-A")
|
||||
_git(work, "commit", "-qm", "a competing release")
|
||||
_git(work, "push", "-q", "origin", "master")
|
||||
|
||||
verify = _run_step("Verify the released manifest on master", work, self._env())
|
||||
|
||||
assert verify.returncode == 1
|
||||
assert "inconsistent" in verify.stderr
|
||||
assert "2026.10.99" in verify.stderr
|
||||
|
||||
def test_the_notes_range_describes_the_developers_commits(self, tmp_path):
|
||||
"""t44 R1, executed: the range the notes use must not open with the bot commit.
|
||||
|
||||
With a write-back the tag IS the bot's commit, so `PREV..TAG` (the range
|
||||
before this fix) published "- chore(release): vX [skip ci]" as the first
|
||||
line of every release. The range is built from ``BUMP``, and this runs both
|
||||
halves through real git.
|
||||
"""
|
||||
work = self._clone(tmp_path)
|
||||
_git(work, "tag", "-a", "v2026.10.08", "-m", "previous release")
|
||||
_git(work, "commit", "--allow-empty", "-qm", "feat: the real change")
|
||||
_run_step("Write the version back into the manifest", work, self._env())
|
||||
_run_step("Create tag", work, self._env())
|
||||
|
||||
def subjects(rev_range):
|
||||
return _git(work, "log", "--pretty=format:- %s", rev_range)
|
||||
|
||||
notes_end = f"{self.TAG}^" # BUMP=yes: the write-back commit is skipped
|
||||
prev = _git(work, "describe", "--tags", "--abbrev=0", f"{notes_end}^").strip()
|
||||
|
||||
assert prev == "v2026.10.08"
|
||||
assert subjects(f"{prev}..{notes_end}") == "- feat: the real change"
|
||||
# ...and the range this replaced really did include the bot's own subject
|
||||
assert "- chore(release):" in subjects(f"{prev}..{self.TAG}")
|
||||
|
||||
def test_a_rejected_bump_push_fails_and_tags_nothing(self, tmp_path):
|
||||
work = self._clone(tmp_path, protect=True)
|
||||
|
||||
bump = _run_step("Write the version back into the manifest", work, self._env())
|
||||
|
||||
assert bump.returncode == 1
|
||||
assert "could not push" in bump.stderr
|
||||
assert "manually" in bump.stderr
|
||||
assert MANIFEST_REL in bump.stderr
|
||||
assert _git(work, "tag", "-l").strip() == ""
|
||||
+159
-175
@@ -9,6 +9,7 @@ migration validity for v2 entries without the field.
|
||||
|
||||
import asyncio # noqa: F401 (async steps need the event loop)
|
||||
import gc
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import warnings
|
||||
@@ -16,16 +17,16 @@ from pathlib import Path # noqa: E402 (F1 test key staging)
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
import voluptuous_mirror as vol_mirror
|
||||
from conftest import _SHIELD_DIR, _load_module
|
||||
from nsh.const import (
|
||||
CONF_ADB_ENABLED,
|
||||
CONF_ADB_HOST,
|
||||
CONF_ADB_PORT,
|
||||
CONF_ANDROIDTV_ENTITY,
|
||||
CONF_HOST,
|
||||
CONF_PROTOCOL_ENABLED,
|
||||
CONF_PROTOCOL_HOST,
|
||||
)
|
||||
|
||||
_STUB_NAMES = (
|
||||
@@ -214,11 +215,7 @@ def _install_stubs() -> None:
|
||||
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")
|
||||
entity_stublib.install_selector_faces(selector)
|
||||
sys.modules["homeassistant.helpers.selector"] = selector
|
||||
|
||||
|
||||
@@ -262,15 +259,10 @@ class FakeHass:
|
||||
@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}
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
_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
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _form_module():
|
||||
@@ -289,12 +281,21 @@ def _entry_module():
|
||||
return sys.modules["nsh.entry"]
|
||||
|
||||
|
||||
def _record(sink):
|
||||
"""An async_set_unique_id stand-in that records what the flow tried."""
|
||||
|
||||
async def _set(unique_id):
|
||||
sink.append(unique_id)
|
||||
|
||||
return _set
|
||||
|
||||
|
||||
def _user_input(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "mdarcy",
|
||||
CONF_ANDROIDTV_ENTITY: "media_player.shield",
|
||||
CONF_PROTOCOL_ENABLED: False,
|
||||
CONF_PROTOCOL_HOST: "",
|
||||
CONF_HOST: "",
|
||||
CONF_ADB_ENABLED: False,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
@@ -306,21 +307,25 @@ class TestStandaloneValidation:
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""}))
|
||||
result = await flow.async_step_user(_user_input())
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "no_state_source"}
|
||||
|
||||
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path):
|
||||
async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path):
|
||||
"""t41: we own the stack — no other integration is a state source."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(_user_input())
|
||||
result = await flow.async_step_user(None)
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"}
|
||||
fields = vol_mirror.declared_fields(result["data_schema"])
|
||||
assert CONF_ANDROIDTV_ENTITY not in fields
|
||||
# the two native transports are what is left to choose from
|
||||
assert CONF_PROTOCOL_ENABLED in fields
|
||||
assert CONF_ADB_ENABLED in fields
|
||||
|
||||
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
|
||||
module = _form_module()
|
||||
@@ -345,29 +350,28 @@ class TestStandaloneValidation:
|
||||
)
|
||||
|
||||
result = await flow.async_step_user(
|
||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True})
|
||||
_user_input(**{CONF_PROTOCOL_ENABLED: True})
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "protocol_pair" # routed into pairing, no error
|
||||
|
||||
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch):
|
||||
"""No androidtv entity → the key is absent from entry data (not "")."""
|
||||
async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
|
||||
"""t41: every value lives in options; `data` carries no leftovers."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow._user_data = {
|
||||
"name": "mdarcy",
|
||||
CONF_ANDROIDTV_ENTITY: "",
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "shield.local",
|
||||
CONF_HOST: "shield.local",
|
||||
}
|
||||
|
||||
# skip the pairing route: drive _create_entry directly
|
||||
result = flow._create_entry()
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert CONF_ANDROIDTV_ENTITY not in result["data"]
|
||||
assert result["data"] == {}
|
||||
|
||||
|
||||
class TestAdbOnlyStandalone:
|
||||
@@ -380,7 +384,6 @@ class TestAdbOnlyStandalone:
|
||||
result = await flow.async_step_user(
|
||||
_user_input(
|
||||
**{
|
||||
CONF_ANDROIDTV_ENTITY: "",
|
||||
CONF_PROTOCOL_ENABLED: False,
|
||||
"adb_enabled": True,
|
||||
}
|
||||
@@ -398,54 +401,79 @@ class TestAdbOnlyStandalone:
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
|
||||
result = await flow.async_step_user(
|
||||
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False})
|
||||
_user_input(**{"adb_enabled": False})
|
||||
)
|
||||
|
||||
assert result["errors"] == {"base": "no_state_source"}
|
||||
|
||||
|
||||
class TestMigrationKeepsOrAllowsAbsence:
|
||||
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
|
||||
class TestMigrationDropsTheEntityKey:
|
||||
"""t41: the androidtv key is dropped from BOTH dicts with a logged note.
|
||||
|
||||
An entry that still carries it keeps working until then (nothing reads it) —
|
||||
the migration is what removes the last trace of the entity path.
|
||||
"""
|
||||
|
||||
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=1,
|
||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
options={},
|
||||
options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
with caplog.at_level(logging.INFO):
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
|
||||
# options were rewritten with the protocol defaults; the data dict —
|
||||
# including the androidtv field — was never touched
|
||||
assert hass.config_entries.updating_entry_calls == [
|
||||
(
|
||||
entry,
|
||||
{
|
||||
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""},
|
||||
"version": 2,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert CONF_ANDROIDTV_ENTITY not in entry.data
|
||||
assert CONF_ANDROIDTV_ENTITY not in entry.options
|
||||
assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
|
||||
assert entry.data == {}
|
||||
# the note names what was dropped, so the log is actionable
|
||||
notes = [record.getMessage() for record in caplog.records]
|
||||
assert any("media_player.shield" in note for note in notes), notes
|
||||
assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
|
||||
|
||||
async def test_v2_without_the_field_is_valid(self, tmp_path):
|
||||
"""Standalone entries born v2 must pass through migration untouched."""
|
||||
async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
notes = [record.getMessage() for record in caplog.records]
|
||||
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
|
||||
|
||||
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
|
||||
"""An entry written before the unification lands on CONF_HOST."""
|
||||
module = _entry_module()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
entry = SimpleNamespace(
|
||||
version=2,
|
||||
data={},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
|
||||
title="mdarcy",
|
||||
)
|
||||
|
||||
ok = await module.async_migrate_entry(hass, entry)
|
||||
|
||||
assert ok is True
|
||||
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
|
||||
assert entry.options[CONF_HOST] == "shield.local"
|
||||
assert "protocol_host" not in entry.options
|
||||
|
||||
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
|
||||
# _form_module owns the scoped stub install/pop — never a bare
|
||||
# entity_stublib.install() here: a shallow stub set leaks into the
|
||||
# entity-module loader of later tests.
|
||||
module = _form_module()
|
||||
from nsh.const import ENTRY_VERSION
|
||||
|
||||
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
|
||||
|
||||
|
||||
def _cmd_player(modules):
|
||||
@@ -517,6 +545,31 @@ class TestAppIconColumn:
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
|
||||
async def test_a_nameless_row_is_rejected_by_the_editor(self, tmp_path):
|
||||
"""t49: the editor already refuses 'com.x=' — a blank name cannot get in.
|
||||
|
||||
The source list filters blanks anyway, because the map can also be
|
||||
hand-edited; this pins the other half: the page will not STORE one.
|
||||
"""
|
||||
module = _form_module()
|
||||
|
||||
assert module._app_map_from_text("com.blank=", {}) is None
|
||||
assert module._app_map_from_text("com.blank= ", {}) is None
|
||||
|
||||
flow = module.NvidiaShieldOptionsFlow()
|
||||
flow.config_entry = SimpleNamespace(options={}, title="Shield")
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_app_map"}
|
||||
assert "app_name_map" not in flow._options # nothing half-stored
|
||||
|
||||
|
||||
class TestChannelEntryOptions:
|
||||
"""P5-2: digit-delay + optional send-enter on the options page."""
|
||||
@@ -567,69 +620,33 @@ class TestChannelEntryOptions:
|
||||
assert result["errors"] == {"base": "invalid_digit_delay"}
|
||||
|
||||
|
||||
class TestUniqueIdSourceG2:
|
||||
"""t29 G2: no unique_id equals the androidtv entity_id anymore."""
|
||||
class TestUniqueIdSource:
|
||||
"""t41: no unique_id is derived from another integration, at any stage.
|
||||
|
||||
def test_entity_unique_id_fallback_is_gone(self):
|
||||
from pathlib import Path
|
||||
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
|
||||
removes the entity path entirely, so the ONLY identity source left is the
|
||||
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
|
||||
"""
|
||||
|
||||
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
|
||||
flow_src = (base / "config_flow.py").read_text()
|
||||
# the historic renameable fallback is gone from the user step
|
||||
def test_no_entity_derived_unique_id_remains(self):
|
||||
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "_unique_id_from_atv_device" not in flow_src
|
||||
assert "async_set_unique_id(androidtv_entity)" not in flow_src
|
||||
# the documented replacement: a device-MAC derive, else no unique_id
|
||||
assert "_unique_id_from_atv_device" in flow_src
|
||||
|
||||
def test_entity_rename_never_moves_the_unique_id(self):
|
||||
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
|
||||
|
||||
# no registry lookups for another integration's devices either
|
||||
assert "device_registry" not in flow_src
|
||||
|
||||
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
|
||||
module = _form_module()
|
||||
# a registered atv entity whose device carries a MAC
|
||||
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv")
|
||||
device = SimpleNamespace(
|
||||
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")]
|
||||
)
|
||||
er_stub = module.er
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||
entities={"media_player.shield": entity}
|
||||
)
|
||||
class _DevRegistry:
|
||||
def __init__(self, by_id):
|
||||
self._by_id = by_id
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
flow.hass = FakeHass(str(tmp_path))
|
||||
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
|
||||
recorded = []
|
||||
flow.async_set_unique_id = _record(recorded)
|
||||
|
||||
def async_get(self, device_id):
|
||||
return self._by_id.get(device_id)
|
||||
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
|
||||
|
||||
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device})
|
||||
|
||||
# the resolved unique_id is the MAC — not the entity_id
|
||||
uid = module._unique_id_from_atv_device(
|
||||
FakeHass("/tmp"), "media_player.shield"
|
||||
)
|
||||
|
||||
assert uid == "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
# renaming the entity does not affect the resolved value
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(
|
||||
entities={"media_player.renamed": entity}
|
||||
)
|
||||
uid_renamed = module._unique_id_from_atv_device(
|
||||
FakeHass("/tmp"), "media_player.renamed"
|
||||
)
|
||||
|
||||
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
|
||||
|
||||
def test_macless_entity_yields_no_unique_id(self):
|
||||
|
||||
|
||||
module = _form_module()
|
||||
er_stub = module.er
|
||||
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
|
||||
assert (
|
||||
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
|
||||
is None
|
||||
)
|
||||
assert recorded == []
|
||||
|
||||
|
||||
class TestListenerComboGone:
|
||||
@@ -816,10 +833,10 @@ class TestIpChangeSelfHealF1:
|
||||
return SimpleNamespace(
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
|
||||
data={},
|
||||
options={
|
||||
CONF_ADB_ENABLED: True,
|
||||
CONF_ADB_HOST: host,
|
||||
CONF_HOST: host, # t41: the entry's one address
|
||||
CONF_ADB_PORT: 5555,
|
||||
},
|
||||
runtime_data=None,
|
||||
@@ -856,11 +873,7 @@ class TestIpChangeSelfHealF1:
|
||||
|
||||
# the config-flow zeroconf path updates the entry's hosts via
|
||||
# async_update_entry — the NEXT built poller targets the NEW address
|
||||
entry.options = {
|
||||
**entry.options,
|
||||
CONF_ADB_HOST: "192.168.1.77",
|
||||
CONF_PROTOCOL_HOST: "192.168.1.77",
|
||||
}
|
||||
entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
|
||||
|
||||
second = factory()
|
||||
assert second is not None
|
||||
@@ -915,7 +928,7 @@ class TestMacLessDiscoveryF2:
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1",
|
||||
unique_id=None, # MAC-less entry
|
||||
options={CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||
options={CONF_HOST: "192.168.1.50"},
|
||||
title="Shield",
|
||||
)
|
||||
self._holder().entries[:] = [entry]
|
||||
@@ -976,7 +989,7 @@ class TestSeedBlockShapeF4:
|
||||
)
|
||||
|
||||
assert blocks[0]["adb_enabled"] is False
|
||||
assert blocks[0]["adb_host"] == ""
|
||||
assert blocks[0]["host"] == "shield.local" # t41: one address field
|
||||
|
||||
def test_adb_block_keeps_adb_on(self):
|
||||
import conftest
|
||||
@@ -1031,8 +1044,7 @@ class TestZeroconfDiscovery:
|
||||
assert result["step_id"] == "user"
|
||||
assert flow._discovered_host == "192.168.1.50"
|
||||
defaults = vol_mirror.defaults_by_key(result["data_schema"])
|
||||
assert defaults[CONF_ADB_HOST] == "192.168.1.50"
|
||||
assert defaults[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||
assert defaults[CONF_HOST] == "192.168.1.50"
|
||||
# ...and a discovery-driven form says the address is already known
|
||||
note = result["description_placeholders"]["discovery_note"]
|
||||
assert "Discovered Shield at 192.168.1.50" in note
|
||||
@@ -1056,7 +1068,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.1"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1067,9 +1079,8 @@ class TestZeroconfDiscovery:
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
# the hosts moved to the discovery address
|
||||
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50"
|
||||
assert entry.options["adb_host"] == "192.168.1.50"
|
||||
# t41: the ONE address field moved to the discovery address
|
||||
assert entry.options[CONF_HOST] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
|
||||
@@ -1078,14 +1089,10 @@ class TestZeroconfDiscovery:
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
# both hosts already hold the discovered address: nothing changes
|
||||
# the address already holds the discovered value: nothing changes
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "192.168.1.50",
|
||||
"adb_host": "192.168.1.50",
|
||||
},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1099,8 +1106,24 @@ class TestZeroconfDiscovery:
|
||||
assert hass.config_entries.updating_entry_calls == [] # no IP change
|
||||
assert hass.config_entries.reloaded_entry_ids == []
|
||||
|
||||
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path):
|
||||
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery."""
|
||||
async def test_the_per_host_heal_cases_collapsed_into_one_rule(self):
|
||||
"""t41: the blank / self-heal / deliberate-override cases are gone.
|
||||
|
||||
They only existed because the address was stored twice (t21 F2). With one
|
||||
field the discovery address simply IS the entry's address, so this pins
|
||||
the simplification: a second address field cannot quietly come back.
|
||||
"""
|
||||
source = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "CONF_ADB_HOST" not in source
|
||||
assert "CONF_PROTOCOL_HOST" not in source
|
||||
|
||||
async def test_an_explicit_address_follows_the_device(self, tmp_path):
|
||||
"""t41: one Shield, one address — discovery is its current source.
|
||||
|
||||
The old rule kept a "deliberately different ADB host" untouched; that
|
||||
override is precisely what the single-field ruling removes.
|
||||
"""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
@@ -1108,37 +1131,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"},
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
sys.modules[__name__]._zc_holder.entries[:] = [entry]
|
||||
|
||||
result = await flow.async_step_zeroconf(
|
||||
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
|
||||
)
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
# only the adb host was filled (self-heal), protocol already current:
|
||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||
assert update["adb_host"] == "192.168.1.50"
|
||||
assert update["protocol_host"] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
|
||||
"""F2: an explicit ADB override survives a protocol-host IP change."""
|
||||
module = _form_module()
|
||||
flow = module.NvidiaShieldConfigFlow()
|
||||
hass = FakeHass(str(tmp_path))
|
||||
flow.hass = hass
|
||||
|
||||
entry = SimpleNamespace(
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
options={
|
||||
CONF_PROTOCOL_ENABLED: True,
|
||||
CONF_PROTOCOL_HOST: "10.0.0.1", # stale (IP moved)
|
||||
"adb_host": "10.0.0.9", # deliberate ADB override
|
||||
},
|
||||
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
)
|
||||
@@ -1150,8 +1143,7 @@ class TestZeroconfDiscovery:
|
||||
|
||||
assert result == {"type": "abort", "reason": "already_configured"}
|
||||
update = hass.config_entries.updating_entry_calls[0][1]["options"]
|
||||
assert update["protocol_host"] == "192.168.1.50" # protocol followed
|
||||
assert update["adb_host"] == "10.0.0.9" # override untouched
|
||||
assert update[CONF_HOST] == "192.168.1.50"
|
||||
assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
|
||||
|
||||
|
||||
@@ -1220,7 +1212,6 @@ class TestAppNameMapResolution:
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = "" # standalone
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("ar.tvplayer.tv") # protocol push
|
||||
@@ -1235,7 +1226,6 @@ class TestAppNameMapResolution:
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._androidtv_entity = ""
|
||||
|
||||
manager = player._manager
|
||||
manager._on_current_app("com.google.android.youtube.tv")
|
||||
@@ -1243,23 +1233,17 @@ class TestAppNameMapResolution:
|
||||
|
||||
assert player.app_name == "com.google.android.youtube.tv"
|
||||
|
||||
async def test_entity_tier_serves_when_no_higher_tier_matches(self):
|
||||
"""P5-1: the entity tier is below map/manifest — it serves when the
|
||||
package has no explicit override and no manifest label yet."""
|
||||
from types import SimpleNamespace as SimpleNS
|
||||
|
||||
async def test_cached_manifest_label_is_the_second_tier(self):
|
||||
"""P5-1: below the explicit map sits the device manifest label."""
|
||||
import entity_stublib
|
||||
|
||||
modules = entity_stublib.load_entity_modules()
|
||||
player, _s, _i = _cmd_player(modules)
|
||||
player._entry.options["app_name_map"] = {} # no override
|
||||
player._androidtv_entity = "media_player.atv"
|
||||
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"})
|
||||
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
|
||||
player._read = lambda eid: state if eid == "media_player.atv" else None
|
||||
manager = player._manager
|
||||
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
|
||||
|
||||
player._manager._on_current_app("ar.tvplayer.tv")
|
||||
manager._on_current_app("ar.tvplayer.tv")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# no cached label yet → falls through to the entity tier
|
||||
assert player.app_name == "ATV Tivi"
|
||||
assert player.app_name == "TiviMate"
|
||||
Reference in new issue
Block a user