James Mackay b2aab15ebf feat(media_player): source list + native select_source from the app list (t47 A)
The last piece a universal-media-player replacement needs: a dropdown of the apps,
an answer to what is on screen, and a way to pick one — all reading ONE app directory
(the user's Apps map, else DEFAULT_APP_DIRECTORY), so the dropdown can never offer a
name the launch path cannot resolve. That invariant is pinned by a test that walks
source_list and resolves every entry.

* `source_list` — friendly names in directory order (the user's map order, or the
  curated list), from a new `_app_directory` accessor that the browse tree now shares.
* `source` — the foreground app's resolved name via the existing `resolve_app_name`
  chain (map → cached device-manifest label → raw package), i.e. exactly `app_name`,
  so the media player and the Current App sensor cannot disagree.
* `select_source(name)` — a configured select_source script still takes precedence
  (t44, unchanged: selecting a source can be more than launching an app); without one
  the name is resolved back to its package and launched over the shared
  `play_media("app", …)` path, so the native semantics are the ones play_media already
  had. Accepts a raw package id (what the browse tree publishes) and matches names
  case-insensitively; an unknown or empty name raises
  ServiceValidationError(translation_domain="nvidia_shield",
  translation_key="unknown_source", translation_placeholders={"source": …}) — a
  translated error, never a silent no-op — with new copy in BOTH strings files.
* SELECT_SOURCE is advertised when EITHER a select_source script is configured OR
  source_list is non-empty; VOLUME_SET stays script-only (the protocol has no
  absolute-volume key), and the two flags are pinned as independent.

Two t44 tests were updated because this is a deliberate behaviour change: "no native
path" is now only true of volume_set, and SELECT_SOURCE is advertised by default
because the curated directory always offers something.

Also: rewriting the strings files re-indented the seven script labels added in t44
(they were valid JSON but sat two levels shallow); the diff there is that fix plus
the new key.

Verified: 441 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 7 awaited HA calls).
2026-10-11 07:05:57 +01:00
2026-04-17 17:04:30 -05:00
2026-04-17 17:04:30 -05:00
2026-04-17 17:04:30 -05:00

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 (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; device research is in 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.

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):

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 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 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.

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, 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

S
Description
Home Assistant integration for NVIDIA Shield TV: ADB media session tracking, Jellyfin stream metadata, smart-plug power monitoring
Readme MIT
1.2 MiB
0 Stars 2 Watchers 0 Forks
v2026.10.12.2
Latest
2026-10-12 14:29:02 +01:00
Languages
Python 99.6%
Shell 0.4%