James Mackay 6aa3b6bfdd feat(adb_command): selectable target, strict resolution, and a real response (t14)
The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
2026-10-12 11:23:47 +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 (vYYYY.MM.DD, plus vYYYY.MM.DD.N for a second release in the same day) — see 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; 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. 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 a power or playback fact (t58 UNIT 0; the earlier wording said "no data", which was too loose). on is the "we cannot know" state and the only situation it describes, so every source that answers — including an answer of "not playing" — stops the ladder at idle at worst, never here:
    • a configured Jellyfin entity in any state: idle, off, standby and unavailable all mean "its session is not playing", so they report — and because the ladder keeps going, a silent Jellyfin still cannot mask a live media session below it;
    • a live media session with no playback flag — the app publishes a session but is not playing, paused or buffering;
    • a bare foreground app — the protocol's current_app push or the ADB mResumedActivity parse answered;
    • a configured power entity, whatever it currently says;
    • protocol is_on / ADB awake, plugged or not. on survives only for an entry where none of those exists, and it 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):

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.

Picking the Shield in the UI. The action declares a target, so Developer tools → Actions (and the automation editor) offers a target picker whose entity list contains the Shield's media_player entity — the same way the core androidtv integration's action worked. Select the Shield there, type the command, and call it. Home Assistant renders that picker from the action's target description, which the integration publishes at setup — that is why the Shield is selectable at all.

As YAML / automation data, the same call:

- action: nvidia_shield.adb_command
  target:
    entity_id: media_player.shield
  data:
    command: getprop ro.product.model
  response_variable: adb            # optional: keep the output in a variable
  • command (required) — the shell command. An empty one is an error (invalid_command), never a silent no-op.
  • The target (required) is the Shield's media_player entity, or its device_id (target: {device_id: …} / entity_id: …). There is no "pick the only one" fallback: with no target the call fails with no_target, two targets fail with ambiguous_target, and an entity that does not participate (its entry has the option off) fails with unsupported_target — a raw shell command is never sent to a Shield you did not select.
  • The output comes back in the response under adb_response — the same key the deprecated androidtv.adb_command action returned, so a template written against that integration keeps reading the same field:
- action: nvidia_shield.adb_command
  target: {entity_id: media_player.shield}
  data: {command: "getprop ro.product.model"}
  response_variable: adb
- action: notify.persistent_notification
  data:
    message: "{{ adb.adb_response }}"

A caller that does not ask for a response is unchanged (the response is optional, not required). If ADB is down the action raises adb_unavailable — an error, never an empty response; a transport failure mid-command propagates as an error too.

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.

Screen capture (opt-in, manual)

The integration can expose an image entity showing the Shield's current screen — sensor-free and off by default. Turn it on in Configure → Main setup → "Add a screen capture image entity"; the entity appears as image.<entry>_screen_capture on the existing device.

Three things about it are deliberate, and they are the whole design:

  • It never drives state. An image entity's state is the timestamp of its last successful capture. Nothing about a capture writes to the media player, the sensors, the power switch or the manager's store — the capture path reads the ADB poller and nothing else. Your media player keeps saying what the DEVICE reported, not what a screenshot happened to show.
  • It never polls. HA sets should_poll = False for image entities and this one keeps it: no scan interval, no timer. A capture happens only when something asks for the bytes (the dashboard showing the entity picture) or when you call homeassistant.update_entity on it. Back-to-back requests are coalesced into one capture and a successful capture is reused for 5 seconds, so a dashboard repaint cannot hammer the Shield.
  • What it costs. Every fresh capture is a full-resolution screen grab (screencap -p) of the current display, base64-encoded, pulled over the same ADB socket the state polling uses, and it keeps that socket busy for the duration. On a 1080p UI that is typically a few hundred kilobytes of PNG (roughly a third more on the wire because of the base64). Treat it as a diagnostic surface you look at when something looks wrong, not as a camera.

It is unavailable — never a blank image — whenever ADB is off, ADB is down, or the last capture failed; the capture_status attribute says which (ok, adb_disabled, adb_disconnected, capture_failed). An explicit homeassistant.update_entity retries after a failure.

How it captures, and why that matters. The transport is screencap -p | base64 over the existing shell, decoded locally with base64.b64decode and checked for the PNG signature and the IHDR chunk before anything is handed to HA. The alternative — writing a temp PNG on the device and pulling it as a file — needs the raw ADB device object and writes to the Shield's shared storage, where a crash between write and delete leaves a screenshot behind; the base64 route leaves no device-side artifact at all. The cost is the extra ~33% on the wire, and one thing is inferred rather than measured: no Shield was available in the development environment, so how the persistent socket copes with a few hundred kilobytes in one shell() call has not been observed on hardware. The decode, the failure paths and the entity contract are covered by tests, including a real-HA run that drives HA's own ImageEntity and content-type validator.

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 / buffering / idle / off, plus on only when no source reported a power or playback fact)
androidtv app name / app_id lookups the manager's store + the one app-name chain: your apps map, then the device's own label for the app on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss), then the built-in app names, then the package id
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

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, Add a screen capture image entity (off by default — see Screen capture), Jellyfin player, power switch
Receiver the receiver/soundbar entity and the input it uses
Apps the apps editor — add, edit or remove one app, or paste the whole list (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, Set up ADB, Pair the remote protocol, Create the entry — so anything you want to correct is one row away instead of buried in a form you have already submitted. The two pairing rows appear only while that transport still needs pairing (a skip leaves its row there to retry; a successful pairing removes it), and the menu's description names what is still to pair.

The Apps table

The Apps row opens a small editor rather than one long form. Home Assistant has no table selector, so a table is edited the way HA edits one — one row per step:

Row What it does
Edit the whole list as text the multiline field below: paste, rename or delete rows in one go
Add an app package id, name to show, and an optional icon
Edit an app pick one of the apps the Shield reported, or one you saved; its name and icon open prefilled
Remove an app pick a saved row, then confirm it on the next step
Back to the main menu leave the editor (nothing is written until Save and close)

Edit and Remove are offered only while there is something to act on: a table with no remembered rows and no discovery yet shows just the text field and Add an app. The editor's description lists every app it knows — the rows you saved, and the ones discovery found, marked [detected]. Edit acts on both (that is the point: an app the device does not name reads as Your Name (package id) when the map or the built-in app names know it, and as package id (no name yet) when nothing does — picking it is how you give it a name, and saving stores it as one of your own rows). Remove acts only on rows you saved, because a detected app has no line to delete (it would simply come back with the next sweep).

A discovered app that nothing can name is never pre-filled into the text field as package=package: a row like that would store the package id as if it were a name. It is offered in the row editor instead, where "no name yet" says what it is. Removing a row never uninstalls anything from the Shield — the app simply stops being offered. Rows you do not touch are left exactly as they were.

The text field is one app per line, in the same format the integration stores:

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 opens carrying the current rows, and it is a multiline input: earlier versions rendered it as a single-line string, so the whole table arrived as one flattened line with nowhere to add a row.

A blank field clears the table (and the integration falls back to its built-in app list). A malformed row — no =, an empty name, a space in the package id or in the icon — is rejected with an error on the page rather than being half-saved, and the Add and Edit forms enforce exactly the same rule.

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. Playback then ensures that switch is on: turn_on, play_media and select_source call switch.turn_on when — and only when — it reports off, and touch nothing else about it (never volume, never a toggle). An already-on switch gets no call at all, a switch whose state cannot be read (unavailable/unknown) is left alone rather than guessed at, and a receiver that is off while the Shield is idle stays off. That is what makes receiver volume scripts unnecessary for this shape: there is no volume to script, only an on/off state to ensure. A media_player.* receiver keeps its own behaviour — input selection and attribute enrichment.

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

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 the built-in app names alone. Pick Discover installed apps on the Configure menu and the table is rebuilt from two ADB queries — and the row then says what happened (see What the Discover row tells you below), because an action that appears to do nothing is indistinguishable from a broken integration:

Command What it returns How it is treated
pm list packages -3 every third-party (user-installed) package, one package:<id> 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.

(Every output shape above is the documented Android one, not an observed capture: no real Shield was available when this was written, so pm list packages -3, resolve-activity --brief and pm dump are exercised from fixtures — the same "inferred, not observed" mark the state=6 buffering value carries in the source ladder above.)

Names come from the same cached path the Current App sensor uses, and there is ONE chain behind every surface — this table, media_player.app_name/source, the Current App sensor and the Apps editor. In order, the first answer wins:

  1. your Apps map — your own edit always wins;
  2. the device's own label for that app, read from pm dump <package>: the probe greps on the device for the label shapes Android prints — application-label:, nonLocalizedLabel= and labelRes= — so only those lines cross ADB (a real dump on one reported Shield is ~2241 lines of dumpsys, and the old head -300 cut it before the package section even began, which is why it looked like "no labels"); a quoted value counts, null and a resource id like 0x7f0f0d31 do not;
  3. the built-in app names (see Sources below) — a curated, community-maintained package → name table;
  4. the package id, shown once (never as pkg=pkg in a row).

A label probe is cached for 24 h, and its outcome is remembered either way: a probe that ran and found nothing is a definitive negative (zero further round trips for that app), while a probe that could not run is not cached at all and is retried on the next poll — this is the same rule as the entity path, and it is why a transient ADB hiccup no longer sticks.

A full sweep runs a handful of probes at a time (4 at a time, 50 ms apart), which finishes a typical Shield in seconds instead of the minutes a strictly serialised sweep would take; the commands share the watchdog's own ADB connection, and adb-shell serialises the I/O on it, so the burst queues politely instead of interleaving on the socket.

Some devices expose no labels at all. The reported Shield is one: nothing in that 2241-line dump names any app. So after 5 probes in a row come back "ran, no label" the integration stops spending a shell per package for the rest of the 24 h TTL: the sweep answers the remaining packages from the map instead of probing them, names come from tiers 1, 3 and 4, and a later device change or update is re-checked when the TTL expires. The Configure menu says so in its verdict when it happens, and the table's name is then your map's name, the built-in name, or empty — an app nothing can name keeps the id in package alone rather than inventing a name out of it.

An app that nothing names is still offered by the Apps row editor (it reads "com.example.app (no name yet)"), which is where you give it one.

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 does not run on the watchdog: the Configure row runs it, the dashboard sensor refreshes the table when it has no fresh one, 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. A sweep that could not run is not cached as an answer: it is recorded as unknown and retried, so a Shield that was asleep at startup is picked up by the next refresh instead of reporting an empty device for six hours.

What the Discover row tells you

The Discover row is an action: it runs a sweep and returns you to the same menu with a one-line verdict, which replaces the usual "not discovered yet" note:

Verdict What it means
N apps found on the Shield — the table is updated. the sweep ran, and the editor's text field now carries the discovered rows
the Shield reported no installed apps — the table keeps your saved rows. the sweep ran and found nothing to add; your rows are untouched
N apps found — this Shield exposes no app labels over ADB, so names come from the built-in app names and your own rows. the sweep ran, and the device itself named nothing: the per-package probes have stopped (see App discovery above)
the discovery sweep could not reach the Shield — check the ADB connection; your saved rows are unchanged. the sweep failed; nothing was cached as a result and nothing you wrote was lost
ADB is not set up for this entry — nothing could be discovered. this entry has no ADB manager (a remote-protocol-only setup), so there is nothing to sweep

The action is best-effort by design: whatever the verdict, the Configure menu still opens and the editor still works on the rows you wrote.

For dashboards the same table is a sensor: sensor.<name>_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, unknown, not_connected or adb_disabled: ok means the table IS the answer — including a genuinely empty one — and unknown means the last sweep could not run (an empty list then says nothing about the device, and the next refresh retries). It only exists for ADB-enabled entries, and it is cheap to refresh — homeassistant.update_entity on it re-runs the sweep only when there is no fresh table (the cache expired, or the last sweep could not run), while the Configure row forces one.

# 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 app-name resolution uses. With no map configured it falls back to the built-in app names, which on a device that exposes no labels is the naming: the table covers the apps the reported Shield has (Projectivy Launcher, Samba (SMB), X-plore, Plex Media Server, TiviMate, Hyperion Grabber, Prime Video, MediaSession2MQTT, Speech Services by Google, Button Mapper, Wholphin, Findroid, Pluto TV, YouTube, Downloader, TV Quick Actions, Argosy, Netflix, RetroArch and the Google TV launcher, plus Google's speech services), so a card shows "Netflix" instead of com.netflix.ninja with nothing configured at all.

That table is a community-maintained convenience, not device truth: package ids are stable, but display names change with vendors and app updates, and an app it has never heard of falls through to its package id. Your own map overrides any row in it — one line in Apps — and discovery fills the Configure table, not this fallback, so the dropdown stays exactly what you saved. An app that discovery found and you want in the dropdown is one row to add.

Apps are sources, not browser media. This integration no longer publishes the app directory through HA's media Browser: the Browse dialog shows an empty tree for this entity (the device name, no rows) instead of a second, inconsistent copy of the same list — the user report was "Media Browser shows Apps. To me it should probably be integrated with the home assistant media display, keep select sources for apps". Everything an app row could do lives in source_list and media_player.select_source below, and nothing else on this entity is browsable, so there is no dead entry to click and nothing else the browser could show.

# 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; with no map, the built-in app names above.
  • source — the app currently on screen, resolved by the same chain the Current App sensor uses: your map first, then the cached device label, then the built-in app name, then the raw package id.
  • 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 holds the package id), names match case-insensitively, and a name that is not in the list is an error rather than a silent no-op:
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: media_player.select_source accepts its own package id.
  • A padded value typed by hand (com.x= Netflix ) is shown cleaned in the dropdown, 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 the rest of the chain instead: the device label, then the built-in app name, then the raw package id.

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
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, metadata and the flat now_playing_* attributes (below)
Current app (sensor.current_app) sensor Currently running app
Now playing (sensor.now_playing) sensor Formatted now-playing string, with its own flat attributes (below)
Pair ADB button Initiate or re-pair ADB connection
Screen capture (image.screen_capture) image The Shield's current screen — opt-in, off by default, manual/diagnostic only (see Screen capture)

Now-playing attributes

Since t65 the media player's now-playing reading is a set of separate TOP-LEVEL attributes, not one nested object — that object read badly in YAML:

Attribute What it holds
now_playing_via which TIER produced the reading: jellyfin, media_session or app
now_playing_state the reading's own state: playing / paused / buffering / idle
now_playing_title the title — or, in privacy mode, the app identity
now_playing_artist, now_playing_album the session's artist and album, when it has them
now_playing_media_type the content type the source reported
now_playing_series, now_playing_season, now_playing_episode series metadata, when the source reports it
now_playing_duration, now_playing_position, now_playing_position_updated_at playback timing
now_playing_thumbnail the artwork URL (or the Jellyfin entity picture)
now_playing_media_info the Jellyfin media-info dict, when that entity has one

The rules that keep the set predictable:

  • Only what the tier answered is published. An unknown field is OMITTED, never null, so nothing ever reads as now_playing_title: null.
  • A device that reads off publishes none of them. A Jellyfin session that has gone stale must not put a title beside a device that is off, and the state already says nothing is playing. on (no source reported a power or playback fact) publishes none either — there is no reading to publish.
  • now_playing_via is deliberately not source. On a media player, source and source_list mean the selected app input (Netflix, TiviMate, …) and they still do; naming the tier source is exactly what made the reading look like media metadata from somewhere else. media_dag_role keeps its own separate meaning (that this entity is the "source" role of the media graph).
  • now_playing_state IS the entity's own state, so a dashboard never reads two different answers: the state ladder is the authority, and the foreground tier's last-known-playback bridge can still publish a title while the state is already idle.

Compatibility: media_dag_now_playing is GONE — not deprecated, removed. It was one object; its contents are the attributes above. No alias is kept: a nested copy would double every poll's payload, and these attributes are declared unrecorded for exactly that reason (they must not bloat the recorder database). A template that read the object needs one rename:

{# before #}
{{ state_attr('media_player.shield', 'media_dag_now_playing').title }}
{# after #}
{{ state_attr('media_player.shield', 'now_playing_title') }}

The Jellyfin-backed sensor.<entry>_now_playing keeps its own flat set (playback_state, title, media_type, series, season, episode, duration, position, thumbnail), and it follows the same rule: a field the Jellyfin entity did not answer is omitted, so there are no title: null rows there either. Its playback_state reads idle whenever that session is not playing. sensor.<entry>_current_app publishes app_id/app_name, the same identity the media player carries beside its now-playing attributes.

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%