# 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. **Intent** — a just-sent command's target state, self-healing against reality within 30 s (retry goes over the transport that served it). 4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active. 5. **ADB media session** — `playing` / `paused` / `buffering` for apps that publish a session (foreground app preferred). `buffering` is the media session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the stock-Android buffering value, **inferred** from that line shape: no captured fixture here contains `state=6` and no real device was available. A buffering session advertises the playback controls exactly like `playing`. 6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the power plug on) and nothing is playing, or ADB reports it asleep (standby). A powered Shield with an app open and nothing playing reads `idle`. 7. **On** — **no source reported anything at all**: no transport connected, no power entity, no Jellyfin, no media session. `on` is the "we cannot know" state and the only situation it describes — it never appears while any source reported a power or media state, and never masks a `playing`/`paused`/`buffering` source. The foreground tier is what keeps an app's identity visible while the state is `idle`: the `current_app` protocol push (then the ADB `mResumedActivity` parse), with its title 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. TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 5–6 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` / `buffering` / `idle` / `off`, plus `on` only when no source reported anything) | | `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the app that is currently on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss) | | `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 The Configure page is a **menu of sub-screens**, not one long form. Home Assistant renders it as a list of rows; each row opens a short form and comes back to the menu, and only **Save and close** commits: | Row | What it holds | | --- | --- | | **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, Jellyfin player, power switch | | **Receiver** | the receiver/soundbar entity and the input it uses | | **Apps** | the apps table — one row per app (see below) | | **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER | | **Save and close** | writes the merged options and reloads the integration | Every field that used to be on the single page is still there, under one of those rows. Edits are merged **per key**, so visiting another sub-screen (or saving from a later one) never drops what an earlier one changed. Half-finished pages are not written: closing the dialog without **Save and close** leaves the entry as it was. The **setup** flow follows the same shape: its first form is unchanged (name, host, transports, the linked entities), and after it the flow is a menu — Receiver / Connection / pairing rows / Create the entry — so anything you want to correct is one row away instead of buried in a form you have already submitted. The pairing rows appear only while a transport still needs pairing. ### The Apps table The **Apps** row is an editable table. One app per line, in the documented format: ``` ar.tvplayer.tv=TiviMate com.plex.hdx=Plex|http://192.168.1.10:9000/plex.png com.netflix.ninja=Netflix ``` `package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or a `camera.*` entity id — see *Sources* below). The field always opens carrying the current rows, so adding, renaming or deleting an app is an edit to that text: - **add a row** — type a new line - **edit a row** — change its name, icon or package id - **remove a row** — delete its line A blank field clears the table (and the integration falls back to its built-in app list). A malformed row — no `=`, or an empty name — is rejected with an error on the page rather than being half-saved. A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or speaker that only knows on and off is a valid receiver. (Driving that switch during playback is a separate, later change.) - **Jellyfin Media Player** — auto-detected or manually selected - **Receiver + Input** — for media-zone coordination; `media_player` or `switch` - **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. ### App discovery (what is actually installed) The Configure page can ask the Shield which apps it has, so the **Apps** table does not start from a four-entry guess. Pick **Discover installed apps** on the Configure menu and the table is rebuilt from two ADB queries: | Command | What it returns | How it is treated | | --- | --- | --- | | `pm list packages -3` | every **third-party** (user-installed) package, one `package:` per line | all of them become rows | | `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended | System apps are **not** enumerated — a Shield ships a few hundred of them and they are not launchable apps — with the one exception that matters: the active launcher, which is a system app yet is exactly what the user sees. Nothing else is filtered, so an app is missing only if Android itself does not report it. Names come from the same cached path the Current App sensor uses: a `pm dump` per package, parsed for `application-label`, cached for 24 h. A full sweep runs the dumps in a small burst (4 at a time, 50 ms apart), which finishes a typical Shield in seconds instead of the minutes a strictly serialised sweep would take. A label fetch that **fails is not cached**, so that app shows its package id in this table and the next sweep tries again — this is the same rule as the entity path, and it is why a transient ADB hiccup no longer sticks. Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so there is no device icon to read. Each app gets a curated `package → mdi:` icon where we know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin — and `mdi:television` otherwise. The icon never enters your **icon column**: that column is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an `mdi:` name there would put a value the media player cannot use into your own map. Discovery never runs on a state poll: it happens when you press the row, and the result is cached for 6 h. With ADB off, or unreachable, the table simply shows the rows you wrote yourself and the menu says why. **For dashboards** the same table is a sensor: `sensor._installed_apps`, whose state is the app **count** and whose `installed_apps` attribute is the list of `{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `not_connected` or `adb_disabled`. It only exists for ADB-enabled entries, and it is cheap to refresh — `homeassistant.update_entity` on it re-runs the sweep only when the cache has expired, while the Configure row forces one. ```yaml # every installed app, with the name this integration would show {{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }} | map(attribute='name') | list }} ``` ### 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. Discovery fills the **Configure** table, not this fallback: the dropdown stays exactly the map you saved, so what a card offers is always what you chose. An app that discovery found and you want in the dropdown is one row to add. ```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