The live Apps page showed the whole map as ONE long line with nowhere to add a
row. Cause: the field was a bare `vol.Optional(...): str`, and HA renders a
selector-less string as a SINGLE-LINE input (pre-fix wire field:
{"type": "string", "name": "app_name_map"} — no selector key). It is a
multiline TextSelector now: the real-HA test pins
selector.text.multiline on the wire and an N-line save/reopen round trip.
HA has no table selector, so the Apps sub-screen is a menu of row actions
(whole list as text / add / edit / remove) instead of one form:
- the EDIT row lists every app the user can act on: the rows they saved AND the
apps discovery found, marked `[detected]`, each labelled "name (package id)"
or "package id (no name yet)" — the user's Shield exposes no device labels at
all, so the unnamed case is the common one and it must not read as its own id
twice, and picking it is how a name gets set (saving stores the row)
- add: package + name + optional icon, refused by the SAME `_valid_app_row`
rule the text field uses, with an error on the page
- edit: PREFILLED from the stored row (suggested values — a voluptuous
`default=` never reaches the browser, so the form would have opened blank);
the device's mdi: icon is never prefilled into the icon column
- remove: pick a SAVED row, then an explicit confirmation menu; cancelling
keeps it (a detected app has no line to delete)
- edit/remove rows are offered only while something is stored
- every mutation writes ONE row through `_write_app_row`/`_drop_app_row`, so
untouched rows and their icons stay byte-for-byte as they were
The Discover row was a silent no-op ("seems to load then do nothing"): it now
returns a one-line verdict the menu shows — N apps found / the Shield reported
none / the sweep could not reach the Shield / ADB is not set up — while still
never raising and never caching a failed sweep as an answer.
Also fixes `async_step_menu_again`, which the real FlowManager dispatches with
user_input and which raised TypeError for every menu offering that row (only
reachable through a stub before, hence unnoticed).
Docs: README and BOTH strings files (byte-identical) describe the editor, the
one-per-line format, the detected-app dropdown and the four Discover verdicts.
654 lines
35 KiB
Markdown
654 lines
35 KiB
Markdown
# NVIDIA Shield
|
||
|
||
Home Assistant integration for the NVIDIA Shield TV — ADB media session tracking for
|
||
now-playing state from any app, plus Google remote-protocol commands, Jellyfin rich
|
||
metadata, and power monitoring.
|
||
|
||
This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/ltomes/ha-nvidia-shield)
|
||
(early 0.1.x), rebuilt around a different architecture: one integration, one device,
|
||
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
|
||
integrations.
|
||
|
||
> **Status:** early fork, actively rebuilt. Releases are date-based
|
||
> (`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 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):
|
||
|
||
```yaml
|
||
nvidia_shield:
|
||
- name: Living Room # the entry's title (default: NVIDIA Shield)
|
||
host: 10.0.0.31 # the Shield's IP — the one address field
|
||
adb_port: 5555 # optional; the standard ADB port
|
||
adb_enabled: true # optional; the block's shape decides (see below)
|
||
```
|
||
|
||
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
|
||
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
|
||
the address is a prefill hint either way, and the PIN/ADB pairing still happens
|
||
in the flow. `adb_enabled` defaults to what the block implies — an address under
|
||
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
|
||
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
|
||
address are ignored with a warning.
|
||
|
||
The seed carries the address, the ADB toggle and the entry's name; everything
|
||
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
|
||
`adb_command` service) is set on the entry's **Configure** page after setup, or
|
||
during the interactive flow.
|
||
Duplicate guards: a block matching an existing entry's host (or its
|
||
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
|
||
host. Removing the YAML block creates or deletes nothing — entries live
|
||
in the storage registry and require user consent to remove. Deleting/re-
|
||
adding is the only way to move an imported entry between hosts; the UI is
|
||
the contractual path (YAML is the seed, the flow is the truth).
|
||
|
||
### Raw `adb_command` service (opt-in)
|
||
|
||
When enabled in the integration's options (`adb_command_enabled`, **default
|
||
off**), the integration registers the `nvidia_shield.adb_command` service:
|
||
send a raw ADB shell command to the entry's persistent connection
|
||
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
|
||
entity is optional when exactly one entry provides the service). This is the
|
||
same security posture as the Home Assistant core `androidtv` integration: a
|
||
documented, opt-in escape hatch for debugging and automation, registered at
|
||
setup and unregistered when the last participating entity unloads. It
|
||
rejects (logs a warning, sends nothing) when the ADB connection is down.
|
||
|
||
## Replacing androidtv / androidtv_remote + the glue
|
||
|
||
This fork replaces the old `androidtv` media player, the `androidtv_remote`
|
||
integration and the surrounding YAML glue — there is nothing to keep alongside
|
||
it: the entry's own two transports cover the whole device.
|
||
|
||
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
|
||
2. **Remove the old platform YAML** — the `media_player:` blocks using
|
||
`platform: androidtv` or `platform: androidtv_remote`, any `universal` media
|
||
player template that fanned them in, and the `remote:`/`switch:` shims you
|
||
added around them.
|
||
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
|
||
feeding them are replaced by this integration (table below).
|
||
4. **Delete the old integrations** in Settings > Devices & Services, then
|
||
**restart** Home Assistant.
|
||
5. **Add this integration** — Settings > Devices & Services > Add Integration >
|
||
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
|
||
then walk the pairing steps.
|
||
6. **Update your automations and dashboards** to the new entities:
|
||
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
|
||
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
|
||
|
||
What each retired piece used to provide, and what covers it now:
|
||
|
||
| Retired piece | Covered by |
|
||
|---------------|------------|
|
||
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `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 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<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`, 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: the
|
||
Shield exposes no app labels of its own, so a detected app reads as `name (package id)`
|
||
or as `package id (no name yet)`, and picking it is how you give it a name — 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).
|
||
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.
|
||
|
||
```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 — 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: 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; 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. 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 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 |
|
||
| `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.
|
||
|
||
```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 app-name resolution
|
||
uses. 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.
|
||
|
||
**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.
|
||
|
||
```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
|
||
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:
|
||
|
||
```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: `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 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
|