Files
ha-nvidia-shield/README.md
T
James Mackay 0357f47284
Release / release (push) Successful in 7s
Test / test (push) Successful in 31s
Test / ha-api-drift (push) Successful in 21s
fix(media_player): dedupe source_list case-insensitively (t53 F1)
t52 F1: the dropdown collapsed exact duplicates but not names that differ only by case.
With a map of {com.a: 'Dup', com.b: 'dup'} the pre-fix tree offered ['Dup', 'dup']
(two rows) while the reverse lookup — case-insensitive by design since t47 — resolved
BOTH spellings to the first entry. So row two was a row that LIED: picking it launched
com.a, the app row one already names. Editor-reachable (two valid lines), unlike the
earlier hand-edit-only edges, which is why it is fixed rather than documented.

source_list now dedupes on name.casefold(), keeping the first spelling in the user's
typed map order. 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 package id
(the t51 rule — both entries kept, each reachable by id) and select_source accepts that
id, pinned by test. The t51 policy is otherwise untouched: names stripped once, blank
name/package entries dropped, exact duplicates already collapsed, well-formed maps
byte-identical to the Browse titles with inner spaces preserved.

README: a short 'Known limits (the Apps map)' note records the two accepted boundaries
so they are known rather than latent — names differing only by case offer one row (the
shadowed app stays reachable by id), and a hand-padded map value is shown cleaned in
the dropdown and the Browse tree while app_name/source/Current App sensor display it
verbatim, so `source` can be a string that is not in source_list (a blank value falls
through to the raw package).

RED PROOF (previous tree swapped in, then restored): 4 failed / 5 passed pre-fix for
the case tests, all green after. The literal pre-fix symptom:
`assert ['Dup', 'dup'] == ['Dup']` — "Left contains one more item: 'dup'"; the
wrong-app half is pinned by `_source_package("dup") == "com.a"`, which holds on BOTH
trees and is exactly the chain that made row two launch row one's app. The new
no-duplicate invariant fails pre-fix with len(['dup', 'dup']) == 2 versus 1.

Verified: 471 passed / 2 skipped, 472 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls); README YAML blocks still parse (5) and the t47/t48/t51
rig is green.
2026-10-11 07:26:07 +01:00

463 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NVIDIA Shield
Home Assistant integration for the NVIDIA Shield TV — ADB media session tracking for
now-playing state from any app, plus Google remote-protocol commands, Jellyfin rich
metadata, and power monitoring.
This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/ltomes/ha-nvidia-shield)
(early 0.1.x), rebuilt around a different architecture: one integration, one device,
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
integrations.
> **Status:** early 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).
## What we're building (the fork's goals)
The fork exists to make a single integration own the Shield end-to-end:
1. **Event-driven ADB media session tracking as the primary playback state source** —
`dumpsys media_session` over a persistent connection, not screen capture, not
10-second polling.
2. **Fallback state for apps without media sessions** — e.g. TiviMate publishes
none at all; foreground-app detection (`mResumedActivity`) + last-known-state
+ the "audio playback" hint from the same session dump close that gap.
3. **All-in-one device** — absorb the Google remote protocol (`androidtvremote2`):
a `remote` entity alongside the media player, `play_media` with
`media_content_type: channel` for TiviMate channel-by-number, absolute volume,
focused-app push events. ADB stays the playback-state source; ADB pairing stays intact.
4. **Retire the surrounding glue** — the `mediasession2mqtt` bridge, universal
media player templates, and external remote entity reading all become native
integration behavior.
Phases 0–4 with files, risks, and definitions of done live in
[`docs/MVP_PLAN.md`](docs/MVP_PLAN.md).
## Current features (inherited from upstream 0.1.x)
- **ADB Media Session** — now-playing from any app that publishes a session
(Spotify, Netflix, Plex, Wholphin, …)
- **Jellyfin rich metadata** — title, series/episode, position, stream details
(resolution, codec, HDR, audio format) via the HA Jellyfin integration's cache
- **Power monitoring** — smart plug for reliable on/off with auto-discovered
sibling sensors (watts, voltage, current)
- **Intent tracking** — commands reflect in HA state immediately, self-heal
against actual device state
- **Privacy mode** — show only app name without media details for non-Jellyfin apps
- **Media DAG attributes** — `media_dag_*` attributes for media-zone style orchestration
Changes inherited from upstream still apply in 0.1.x code: polling is timer-based,
the ADB connection lives inside the media player entity, and fallback app state
arrives in Phase 3.
## State ladder and fallback semantics (Phase 3)
The media player state is decided top-down; each tier serves only when the
ones above it are silent. Both transports live in the entry's
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
remote protocol is the fast push/command path:
1. **Plug off** — the power switch says `off` → `off`.
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
4. **Intent** — a just-sent command's target state, self-healing against
reality within 30 s (retry goes over the transport that served it).
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
6. **ADB media session** — `playing` / `paused` for apps that publish a
session (foreground app preferred).
7. **Foreground fallback** — an app is open (`current_app` protocol push,
then the ADB `mResumedActivity` parse) but publishes no media session →
`on`. Its identity comes from the TTL-capped last-known-state cache, the
last channel entered, or the zero-cost "audio playback" hint
(`media recently active`) — never resurrecting `playing` past the 60 s
state TTL.
8. **On** — powered, awake, nothing more specific.
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
the audio hint are what keep it visible.
**Standalone setup** offers both state sources at once — the Nvidia Remote
Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
Shield's address pre-filled when discovery found it. Either can be turned off,
and each pairing step can be skipped (that transport stays off until you pair
it), so ADB-only, protocol-only and fully passive entries are all one click
apart. The `no_state_source` form error fires only when neither is enabled.
There is no other integration in the loop: one address, two transports, and the
integration reads the device itself.
### Channel entry options
The options flow exposes the §7 channel behavior the integration replicates:
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
**ENTER press** after the digits (off by default — TiviMate's validated
behavior). Both are consumed by `play_media` with
`media_content_type: channel`.
### YAML seeding (optional, seed-only)
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
interactive setup flow — nothing is created without the user walking the
standard flow, and pairing stays interactive exactly as with UI setup. The UI is
the primary path; the block exists so a provisioning/fleet setup can supply the
address and the transport choice.
A complete, copy-pasteable block (every key is optional except an address):
```yaml
nvidia_shield:
- name: Living Room # the entry's title (default: NVIDIA Shield)
host: 10.0.0.31 # the Shield's IP — the one address field
adb_port: 5555 # optional; the standard ADB port
adb_enabled: true # optional; the block's shape decides (see below)
```
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
the address is a prefill hint either way, and the PIN/ADB pairing still happens
in the flow. `adb_enabled` defaults to what the block implies — an address under
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
address are ignored with a warning.
The seed carries the address, the ADB toggle and the entry's name; everything
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
`adb_command` service) is set on the entry's **Configure** page after setup, or
during the interactive flow.
Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live
in the storage registry and require user consent to remove. Deleting/re-
adding is the only way to move an imported entry between hosts; the UI is
the contractual path (YAML is the seed, the flow is the truth).
### Raw `adb_command` service (opt-in)
When enabled in the integration's options (`adb_command_enabled`, **default
off**), the integration registers the `nvidia_shield.adb_command` service:
send a raw ADB shell command to the entry's persistent connection
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
entity is optional when exactly one entry provides the service). This is the
same security posture as the Home Assistant core `androidtv` integration: a
documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down.
## Replacing androidtv / androidtv_remote + the glue
This fork replaces the old `androidtv` media player, the `androidtv_remote`
integration and the surrounding YAML glue — there is nothing to keep alongside
it: the entry's own two transports cover the whole device.
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
2. **Remove the old platform YAML** — the `media_player:` blocks using
`platform: androidtv` or `platform: androidtv_remote`, any `universal` media
player template that fanned them in, and the `remote:`/`switch:` shims you
added around them.
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
feeding them are replaced by this integration (table below).
4. **Delete the old integrations** in Settings > Devices & Services, then
**restart** Home Assistant.
5. **Add this integration** — Settings > Devices & Services > Add Integration >
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
then walk the pairing steps.
6. **Update your automations and dashboards** to the new entities:
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
What each retired piece used to provide, and what covers it now:
| Retired piece | Covered by |
|---------------|------------|
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `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`, 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 (`.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.)
## Installation
### HACS
HACS only works with public GitHub repositories, so the Gitea repo cannot be
added to HACS directly. If you set up a public GitHub mirror of the releases,
add it as a custom repository:
1. In HACS, open the overflow menu (⋮) → **Custom repositories**.
2. Paste `https://github.com/notexpectedyet/ha-nvidia-shield`, pick type
**Integration**, click **Add**.
3. Find **NVIDIA Shield** in the HACS list and click **Download**.
4. **Restart Home Assistant.**
### Manual
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/`
directory, restart, then add the integration from **Settings → Devices & Services**.
## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. 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
ADB is paired during setup via a dedicated config flow step. If you need to
re-pair later (e.g. after factory reset), use the **"Pair ADB"** button on the
device page in HA.
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
### Discovery (zeroconf)
Home Assistant can discover a Shield through the `_androidtvremote2._tcp`
service, and the discovered address is pre-filled on the setup form. The dedup
rules are Home Assistant's own, and they differ with what the discovery carries:
- **with** the device MAC, a duplicate aborts as "already configured", and an
existing entry's address is updated when it changed;
- **without** a MAC, the flow follows HA's stricter
`_async_handle_discovery_without_unique_id` contract: **any** existing entry
for this integration aborts the discovery, and only one discovery flow runs at
a time.
So a second (or third) Shield that advertises no MAC has to be added **manually**
— Settings > Devices & Services > Add Integration > "NVIDIA Shield" — rather than
by discovery. That is a deliberate, tested contract (t37 D3), not a bug: the
helper the flow calls is a coroutine and its dedup only runs when awaited.
### Options
- **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** — for media-zone coordination
- **Power Switch** — smart plug entity
- **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
| Entity | Type | Description |
|--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata |
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
| Pair ADB | `button` | Initiate or re-pair ADB connection |
## Development
- **Repository:** [Gitea — git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield), branch `master`
- **CI/CD:** Gitea Actions; every push to `master` releases (see
`.gitea/workflows/release.yml`). Auth uses the job-provided `GITEA_TOKEN` —
no extra secrets required.
- **Shared ADB helpers** are vendored at `custom_components/nvidia_shield/ha_adb_common/`
(trimmed in this fork); dev extras in `pyproject.toml` (`uv sync --extra dev`).
## License
MIT