Both observations from the t8-t12 batch review, settled by execution rather than
by argument.
O2 — WHICH STATES MAY CARRY A NOW-PLAYING ROW. The review read
`extra_state_attributes` and argued the row publishes the entity state verbatim,
so `now_playing_state: on` is reachable, while the README documents only
playing/paused/buffering/idle; t10's author had reported that `on` and `off`
publish no row at all. The probe settles both halves:
* the t10 account is true on the NATURAL ladder — `on` means no source reported a
power or playback fact, and every tier that can produce a reading sets
`reported = True` in the ladder, so the two cannot meet;
* the review is right about the RULE. It was "publish unless the state is off", an
assumption nothing enforced: forcing the ladder to `on` (and to `standby`, which
the README does not even mention) published `now_playing_state: on`/`standby`
beside a real title.
So the rule is now explicit — `NOW_PLAYING_STATE_VALUES = ("playing", "paused",
"buffering", "idle")` — and a reading is dropped whole when the entity state is
anything else, exactly as `off` always dropped it. Nothing legitimate is lost: a
tier only ever answers in one of those four (`idle` for "reported, nothing
playing", which is where the foreground tier's remembered-playback bridge lands).
The README says the same thing in the table row and in prose, and
tests/test_now_playing_publication.py walks EVERY `MediaPlayerState` member with a
reading present (the four publish themselves; on/off/standby publish nothing),
then reads the four values back OUT OF THE README and compares them to the
constant, so code and docs cannot drift again.
O3 — A REJECTED APP PASTE NAMES ITS BAD LINES. The bulk paste was all-or-nothing
and reported a generic `invalid_app_map`, so a 30-row paste with one typo had to
be bisected by hand. `_app_map_parse` now returns `(map | None, problems)` where
each problem carries the 1-based line NUMBER, the line as the user typed it, and
why (empty/space-bearing package, empty name, space in the icon), and the
rejection puts them in the form's `description_placeholders["invalid_lines"]`,
which the error copy interpolates — capped at 4 named lines with the remaining
count, so the message stays readable. The map is still None when anything is
wrong (never half-stored) and the detail is cleared as soon as a paste parses.
The single-row editor is untouched and keeps its own `invalid_app_row`; a test
asserts the new per-line report and `_valid_app_row` accept and refuse the same
rows, so a paste and a typed row cannot disagree.
Verify: 794 passed / 4 skipped, 795 passed / 3 skipped (with androidtvremote2),
ruff clean.
946 lines
53 KiB
Markdown
946 lines
53 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.
|
||
|
||
**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.
|
||
|
||
### 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
|