# 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."}` — 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.`, `remote.`, `sensor._current_app`, `sensor._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 [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