Files
ha-nvidia-shield/README.md
T
James Mackay 259a6c29bd feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2)
Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.

THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.

Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.

The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.

THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.

THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.

THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.

test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).

Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
2026-10-12 12:54:41 +01:00

1054 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. `registered_intents:` is the one exception — it may
be seeded too, because registering several intents by hand is exactly the kind of
work a `configuration.yaml` should be able to do:
```yaml
nvidia_shield:
- name: Living Room
host: 10.0.0.31
registered_intents:
- id: netflix_title # the button's key (optional)
name: "Netflix: open a title" # what the button and the action call it
package: com.netflix.ninja
activity: com.netflix.ninja/.MainActivity # a component…
action: android.intent.action.VIEW # …or an action (+ categories)
categories: [android.intent.category.DEFAULT]
data: "https://www.netflix.com/watch/{id}" # {placeholders} from the fields
fields:
- {key: id, type: string, required: true}
```
A seeded intent is **identical to one you typed** — same validation, same button,
same `send_intent` behaviour. The seed is honoured **only while the UI holds
nothing**: it is applied when the entry is created (the Intents screen lives in
the options flow and never writes a seed back), and an existing entry is never
re-seeded. A seeded row that does not validate is **dropped with a warning naming
it**, because there is no form to show an error on in `configuration.yaml`.
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:
```yaml
- 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:
```yaml
- 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.
### Intents (buttons and the `send_intent` action)
An **intent** is one thing an app can be asked to do: open a screen, follow a
deep link, handle an action. This integration carries them over ADB, in two
halves.
**Discovery** asks the Shield what ONE app advertises. The **Scan an app's
intents** row on the Intents screen runs it for the app you pick: the resolver
section of that app's `pm dump` is filtered *on the device* (the whole dump is
~2241 lines), only the section crosses the connection, and the answer is cached
per package for an hour. A scan never registers anything — it offers candidates,
and you pick one. A candidate whose path is a *pattern* (`/title.*`) prefills no
URL on purpose: a pattern is not a link you can open.
**Registration** turns a candidate (or something you type) into a stored intent.
Configure → **Intents** → *Add or edit an intent*:
| Field | What it is |
| --- | --- |
| Name | what the button and the action's dropdown call it; unique per entry |
| Package id | the app's Android package id, exactly as the Shield reports it |
| Activity (component) | the component to launch, `pkg/.Activity` — use this **or** the action |
| Action | an Android action such as `android.intent.action.VIEW` |
| Categories | optional, comma separated (`android.intent.category.DEFAULT`) |
| Data (URL template) | optional URL/URI; `{variable}` placeholders are filled from the declared variables |
| Variables (declared fields) | one per line: `key:type[:required][=value]`, where type is `string`, `int`, `bool` or `raw` |
**One button per registered intent.** Adding an intent adds `button.<name>`
pressing it sends the intent with the declared defaults. A declared field that is
`required` with no default makes the press an error naming the field — such an
intent is meant to be called through the action, which is where variables come
from. Removing the intent removes its button (an options change reloads the
entry). The button's attributes show exactly what it will send.
**The action** takes the intent's name and its variables:
```yaml
- action: nvidia_shield.send_intent
target:
entity_id: media_player.shield
data:
intent: "Netflix: open a title"
variables:
id: "80100172"
response_variable: fired # optional: keep the output in a variable
```
- `intent` (**required**) — the registered name, as the Intents screen shows it.
An unknown one raises `unknown_intent` and **lists the known names**.
- `variables` — one value per declared field. A declared field with a default may
be omitted; a `required` one raises `missing_intent_field`, **naming the field**.
A variable the intent never declared is **refused** (`unknown_intent_variable`):
nothing silently travels to the device, and nothing is silently dropped. A value
that cannot become its declared type names the field and the value
(`invalid_intent_field`).
- The **target is required** and resolves exactly like `adb_command`: one Shield
(`no_target` / `unsupported_target` / `ambiguous_target`), never a guess.
- **The device's own output comes back** in the response under `intent_response`
(`am start` prints `Starting: Intent {…}` when it accepted the intent), so a
caller that asks for a response can see what happened.
- **Failures are loud, and the device's words are kept.** A Shield that refuses
the intent raises `intent_failed` carrying *its* message ("unable to resolve
Intent", "does not exist", "Permission Denial") — never a silent success. ADB
being off, unreachable, or answering nothing raises `intents_need_adb` /
`intent_no_answer`: the intents feature **requires ADB**, because the remote
protocol cannot carry an intent, and the error says so instead of failing
obscurely.
**An intent the app rejects is a configuration problem.** The app may no longer
handle that action, the component may have been renamed by an update, or the
intent may need a permission it does not have. The error shows the Shield's own
message and the fix is the registration — the integration never retries, guesses
or falls back to another intent.
**How the command is built (and why it is safe).** `am start -n <component>
-a <action> -c <category> -d <data> --es/--ei/--ez key value`, assembled from the
stored fields. `{placeholder}` substitution is plain text replacement — **no
`eval`, no `format()`, no shell interpolation** — and every value is quoted, so a
variable whose value is `"; rm -rf /"` reaches the Shield as literal data inside
one argument. This is deliberately **not** a second raw-shell door:
`nvidia_shield.adb_command` remains that one, and this action can only ever send
an intent you registered.
### 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`](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`, `Add a screen capture image entity` (off by default — see [Screen capture](#screen-capture-opt-in-manual)), 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.
Turning **Add the Nvidia Remote Protocol** on in the **Main setup** row of the
*options* page does not silently enable a client that cannot connect: an entry that has
never completed a pairing is taken to the same **Pair the remote protocol** step, and
its **Continue without pairing** escape turns the option back off. Pairing is remembered
per entry, so switching the transport off and on again on a paired entry asks for no new
PIN (the certificate from the first pairing is reused — see
[is my transport paired?](#is-my-protocol-transport-paired-connected-or-not-running-at-all)).
### 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.
```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 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.
```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 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.
```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; 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:
```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 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 |
```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.
### The remote entity: the app / activity picker
The `remote` entity speaks Home Assistant's own remote contract, matching the
`androidtv_remote` entity it replaces — so a replaced setup keeps its automations
**and** gets the app picker the frontend only renders for a remote that declares it:
| Piece | Value |
| --- | --- |
| `supported_features` | includes `RemoteEntityFeature.ACTIVITY` — without that flag Home Assistant offers **no activity picker at all**, which is why the remote card can look empty |
| `activity_list` | the friendly app names, in your order: your **Apps table** (or the built-in table when you have none), plus the apps **ADB discovery** actually found on the device |
| `current_activity` | the app on screen as a friendly name: the protocol's current-app push when it arrives, the ADB foreground reading otherwise. `None` while nothing is known — no activity is ever invented |
| `turn_on` with `activity:` | wakes the Shield when it reports itself off, then launches that app over the protocol |
```yaml
# launch Netflix by the name from the activity list / Apps table
action: remote.turn_on
target:
entity_id: remote.living_room
data:
activity: Netflix
```
An `activity` resolves in this order: a **name** from the activity list
(case-insensitive), then a **package id** or a **detail link** (`https://…`,
`market://…`) passed through unchanged — that is how an app your table does not carry
is still launchable, and what the other integration does with a name it does not know.
Anything else raises a clear error naming the value instead of silently doing nothing.
Leaving `activity` out (or empty) keeps the old behaviour exactly: wake through the
configured script, else `POWER` when the device reports it is off.
**Where the list comes from, and why.** `androidtvremote2` exposes **no app-list API
at all** (measured against the installed library) — which is exactly why
`androidtv_remote` keeps a user-maintained app map. This integration has the same kind
of source *plus* something the other one lacks: ADB discovery, so the picker can offer
what is actually installed and name it through the one chain (your Apps table → the
device's own label → the built-in table → the package id). Apps nothing can name are
not listed as bare package ids; pass their id to `activity` if you need them.
#### Is my protocol transport paired, connected, or not running at all?
The library offers no `is_paired` / `is_connected`, so the remote entity publishes what
can be **proven** (Developer Tools > States, or
`state_attr('remote.living_room', 'protocol_state')`):
| Attribute | Meaning |
| --- | --- |
| `protocol_state` | `off` (no transport configured), `never_connected`, `connected`, `disconnected`, or `auth_rejected` |
| `protocol_connected` | `true` only while the device reports the protocol session up |
| `protocol_ever_authenticated` | `true` once a session connected **or any push arrived** — the only proof of a paired client the library gives |
| `protocol_current_app_seen` / `protocol_is_on_seen` / `protocol_volume_seen` | whether each device push has **ever** arrived (sticky: the state cache expires, these do not) |
| `protocol_last_error` / `protocol_failures` | the last protocol connect/send failure verbatim, and how many sends could **not** run |
| `protocol_certificate` | `present` / `missing` — the certificate from your setup pairing, which is why enabling the transport on a paired entry asks for **no new PIN** |
| `power_on_route` / `power_off_route` | `protocol`, or `script:<script>` when a command script owns that command |
| `activity_source` (and the media player's `app_source`) | which tier produced the app identity: `protocol` (the device's own push) or `adb` (the dumpsys parse) — the push is preferred, and it is never a silent override |
Reading it:
* **`auth_rejected`** — the device refused our certificate (a factory reset, or a
deleted `.storage/nvidia_shield/client.pem`). Home Assistant also flags the entry
for reauthentication; that flow re-pairs.
* **`never_connected`** while the transport is enabled — switched on but with no
session. Pairing again is the answer (the options flow routes an unpaired entry
straight into the pairing step rather than enabling a client that cannot connect).
* **`off`** — the transport is not configured. Everything else here still works: ADB
is the state source, so an ADB-only setup degrades **only** the protocol surface.
* A send that could not run is **never silent**: it is counted, recorded verbatim in
`protocol_last_error`, and logged at WARNING together with `protocol_state`.
**The other Android TV integration.** `androidtv_remote` pairs **its own client** and
shares nothing with us: its certificate lives in its own storage and its entities are
its own. Ours (`client.pem` under `.storage/nvidia_shield/`) is reused between pairing
and runtime by design, so *not* being asked for a PIN on an already-paired entry is
expected — and it says nothing about whether the session is up. `protocol_state`
answers that.
**A configured script bypasses the protocol.** When a command script owns `turn_on`
(or `turn_off`), that script runs and the protocol is **never touched** — so a power
press that worked through it is *not* evidence that the transport is paired, or even
connected (`power_on_route` reads `script:…`). A configured **Remote command script**
is the same story for `remote.send_command`. Only a command that went out over the
protocol, with `protocol_state` = `connected`, exercises the transport.
## 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) |
| Remote | `remote` | Key commands, the activity/app picker, and the transport-state attributes (see [the activity picker](#the-remote-entity-the-app--activity-picker)) |
| 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](#screen-capture-opt-in-manual)) |
### 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 — exactly one of `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_state` is one of FOUR values, and `on` is not one of them.** The
full set is `playing`, `paused`, `buffering`, `idle` — the states a source can
report when it has something to say. The row is published only while the entity
state is one of those four, so `on` (nothing reported), `off` and any frontend-
or app-side state can never appear beside a title. The list is code
(`NOW_PLAYING_STATE_VALUES` in `media_player.py`) and a test compares THIS
paragraph and the table above against it, so neither can drift.
- **`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:
```jinja
{# 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](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