docs(readme): replacement guide + a verified seed example (t41)

The README now answers the question a migrator actually has: what do I delete,
and what covers it afterwards.

* a new "Replacing androidtv / androidtv_remote + the glue" section: the
  back-up → remove-the-platform-YAML → remove-mediasession2mqtt → delete the old
  integrations → add this one → update automations checklist, plus a table
  mapping every retired piece to what serves it now. It notes that Wholphin
  publishes no MediaSession of its own and gets its now-playing from the ADB
  media session, so no bridge is needed for it either;
* the setup steps describe the real flow (name + one address, both transports on
  by default, ADB pairing then PIN pairing, each with a skip);
* the YAML seed section carries a complete, copy-pasteable block. It documents
  ONLY the keys `_yaml_seed_blocks` honours (name, host, adb_port, adb_enabled,
  and the still-accepted protocol_host/adb_host) and says explicitly that the
  rest lives on the Configure page — an example that lists ignored keys is worse
  than no example;
* the state-ladder section, the discovery bullet and the options list follow the
  two-transport, one-address reality.

`tests/test_readme.py` pins the doc/code contract: the example's keys are a
subset of what the seed reads, the example uses the current `host` key, and the
guide keeps naming the retired integrations and glues.

Verified: 326 passed / 2 skipped, ruff clean.
This commit is contained in:
James Mackay committed 2026-10-11 06:21:40 +01:00
1 parent fb481c3c14
commit 151c49a0da
2 files changed
+145 -24

No files matched your search

+82 -24
View File
@@ -79,10 +79,15 @@ TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
the audio hint are what keep it visible. the audio hint are what keep it visible.
**Standalone setup** accepts any one of the three state sources — the Android **Standalone setup** offers both state sources at once — the Nvidia Remote
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
media-session mode (ADB enabled + host). The `no_state_source` form error Shield's address pre-filled when discovery found it. Either can be turned off,
fires only when none of the three is present; ADB-only setups are valid. 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 ### Channel entry options
@@ -96,17 +101,32 @@ behavior). Both are consumed by `play_media` with
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
interactive setup flow — nothing is created without the user walking the interactive setup flow — nothing is created without the user walking the
standard flow, and pairing stays interactive exactly as with UI setup: 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 ```yaml
nvidia_shield: nvidia_shield:
- name: Living Room - name: Living Room # the entry's title (default: NVIDIA Shield)
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint host: 10.0.0.31 # the Shield's IP — the one address field
adb_port: 5555 adb_port: 5555 # optional; the standard ADB port
adb_enabled: true # optional; the block's shape decides (see below)
``` ```
`protocol_host` is a prefill hint only (the PIN pairing still happens in `host` is the current key. `protocol_host` and `adb_host` are **still accepted**
the flow). Blocks without protocol/adb hosts are ignored with a warning. (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 Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live host. Removing the YAML block creates or deletes nothing — entries live
@@ -126,15 +146,45 @@ documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down. rejects (logs a warning, sends nothing) when the ADB connection is down.
### Migration from mediasession2mqtt + universal-player glue ## Replacing androidtv / androidtv_remote + the glue
The `mediasession2mqtt` bridge sensors and the universal media player YAML This fork replaces the old `androidtv` media player, the `androidtv_remote`
templates the fork replaces are retired by the ladder above: the media player integration and the surrounding YAML glue — there is nothing to keep alongside
entity already carries the DAG attributes (`media_dag_running_apps` with it: the entry's own two transports cover the whole device.
foreground/last-known enrichment, `media_dag_now_playing` with its source
tag) that media-zone orchestration consumed from those sensors. See the 1. **Back up** your `configuration.yaml` and note the Shield's IP address.
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the 2. **Remove the old platform YAML** — the `media_player:` blocks using
retirement mapping and the universal-player glue it replaces. `platform: androidtv` or `platform: androidtv_remote`, any `universal` media
player template that fanned them in, and the `remote:`/`switch:` shims you
added around them.
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
feeding them are replaced by this integration (table below).
4. **Delete the old integrations** in Settings > Devices & Services, then
**restart** Home Assistant.
5. **Add this integration** — Settings > Devices & Services > Add Integration >
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
then walk the pairing steps.
6. **Update your automations and dashboards** to the new entities:
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
What each retired piece used to provide, and what covers it now:
| Retired piece | Covered by |
|---------------|------------|
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `idle` / `off`) |
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
comes from the ADB media session — the same tier every other app uses — so it
needs no bridge either.
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
full retirement mapping this section summarises.
## Releases ## Releases
@@ -169,10 +219,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
## Configuration ## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield" 1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. Select the Android TV Remote entity for your Shield 2. Give it a name and its address — the Shield's IP, pre-filled when discovery
3. Enable ADB if you want now-playing from all apps found the device
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield 3. Both transports are on by default: **Read state over ADB** (now-playing from
5. Optionally configure Jellyfin, receiver, power switch 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 Pairing
@@ -189,7 +244,7 @@ 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: 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 - **with** the device MAC, a duplicate aborts as "already configured", and an
existing entry's hosts are updated when the address changed; existing entry's address is updated when it changed;
- **without** a MAC, the flow follows HA's stricter - **without** a MAC, the flow follows HA's stricter
`_async_handle_discovery_without_unique_id` contract: **any** existing entry `_async_handle_discovery_without_unique_id` contract: **any** existing entry
for this integration aborts the discovery, and only one discovery flow runs at for this integration aborts the discovery, and only one discovery flow runs at
@@ -205,7 +260,10 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
- **Jellyfin Media Player** — auto-detected or manually selected - **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** — for media-zone coordination - **Receiver + Input** — for media-zone coordination
- **Power Switch** — smart plug entity - **Power Switch** — smart plug entity
- **ADB Host/Port** — override auto-detection if needed - **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
## Entities ## Entities
+63
View File
@@ -0,0 +1,63 @@
"""tests/test_readme.py — the README's user-facing contracts (t41).
Documentation drifts silently: the seed example is read by people who then
report "the key did nothing". These pins are deliberately textual — no YAML
parser dependency — but they check the two things that actually hurt:
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
honours (nothing documented that the integration ignores);
* the replacement guide names the integrations and the glue it retires, so the
t41 ruling stays visible to users, not only in the changelog.
"""
import re
from pathlib import Path
import pytest
README = Path(__file__).resolve().parent.parent / "README.md"
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
# seed block reaches the entry (the rest is the interactive flow's or the
# options page's to set).
SEEDED_KEYS = {
"name",
"host",
"adb_port",
"adb_enabled",
"protocol_host", # legacy, still accepted
"adb_host", # legacy, still accepted
}
@pytest.fixture(scope="module")
def readme_text() -> str:
return README.read_text(encoding="utf-8")
def _seed_example(text: str) -> str:
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
assert match, "the README has no `nvidia_shield:` example block"
return match.group(1)
def test_the_example_block_only_uses_seeded_keys(readme_text):
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
assert keys, "the example block declares no keys"
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
def test_the_example_block_documents_the_one_address_field(readme_text):
example = _seed_example(readme_text)
assert "host:" in example
# the legacy spellings are still explained, as back-compat
assert "protocol_host" in readme_text and "adb_host" in readme_text
def test_the_replacement_guide_retires_the_old_stack(readme_text):
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
assert needle in readme_text, needle
# and it says the UI is the primary path
assert "primary path" in readme_text