Releases: the section described the manifest/date fallback only, and pointed at `.github/workflows/release.yml`, which does not exist. It now covers the same-day `vYYYY.MM.DD.N` suffix (lowest free N, gaps reused), the manifest write-back commit and WHY it exists (HACS compares against the default-branch manifest, so a tag-only release is offered to nobody — v2026.10.09 shipped that way), what `[skip ci]` means and what it does not (release commits only; Gitea's own handling can still skip an ordinary commit whose body mentions the marker), the refusal to publish when the default branch disagrees, and the branch-protection failure path. t44 R2, both halves of the tag's identity: with a write-back the tag names the bot commit, so `git describe --tags` on a developer commit reports the previous release (expected, not a missing tag); hand-bumping the manifest keeps the tag on the developer's own commit, because the workflow then has nothing to write back. Command scripts: a new section under Options, with the command/variable table (`level` for volume_set, `is_volume_muted` for volume_mute, `app_name` for select_source), the "instead of, never both" rule, the fact that an unset selector means the native path, and a copy-pasteable script block covering the three cases the user asked for — a speaker switch owning the volume, an AVR owning the input, a mute that hits the switch too. It says plainly that scripts are set on the Configure page and not in the YAML seed, which stays address + transport only (the seed example is unchanged and still the only `nvidia_shield:` block). Verified: 414 passed / 2 skipped, 415 / 1 with androidtvremote2, test_config_flow_ux 61, test_standalone_setup + test_config_flow_protocol 58, ruff clean, drift engine exit 0 under real HA, and every fenced YAML block in the README parses with pyyaml.
378 lines
19 KiB
Markdown
378 lines
19 KiB
Markdown
# 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 (`v2026.10.08`).
|
||
> Expect rough edges until the MVP phases land. The build-out plan is in
|
||
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
|
||
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
|
||
|
||
## 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; 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 (no native path) | `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. `volume_set` and
|
||
`select_source` only appear as services while their script is configured, because
|
||
there is nothing native to fall back on.
|
||
|
||
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 an 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.
|
||
|
||
## 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
|