From 151c49a0dab318d89ec0fa59dc5bcf1169c3217c Mon Sep 17 00:00:00 2001 From: James Mackay Date: Sun, 11 Oct 2026 06:21:40 +0100 Subject: [PATCH] docs(readme): replacement guide + a verified seed example (t41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 106 +++++++++++++++++++++++++++++++++---------- tests/test_readme.py | 63 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 tests/test_readme.py diff --git a/README.md b/README.md index bdd7988..fbb6739 100644 --- a/README.md +++ b/README.md @@ -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 the audio hint are what keep it visible. -**Standalone setup** accepts any one of the three state sources — the Android -TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB -media-session mode (ADB enabled + host). The `no_state_source` form error -fires only when none of the three is present; ADB-only setups are valid. +**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 @@ -96,17 +101,32 @@ behavior). Both are consumed by `play_media` with 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: +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 - adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint - adb_port: 5555 + - 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) ``` -`protocol_host` is a prefill hint only (the PIN pairing still happens in -the flow). Blocks without protocol/adb hosts are ignored with a warning. +`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 @@ -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 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 -templates the fork replaces are retired by the ladder above: the media player -entity already carries the DAG attributes (`media_dag_running_apps` with -foreground/last-known enrichment, `media_dag_now_playing` with its source -tag) that media-zone orchestration consumed from those sensors. See the -appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the -retirement mapping and the universal-player glue it replaces. +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.`, `remote.`, `sensor._current_app`, + `sensor._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 @@ -169,10 +219,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi ## Configuration 1. Settings > Devices & Services > Add Integration > "NVIDIA Shield" -2. Select the Android TV Remote entity for your Shield -3. Enable ADB if you want now-playing from all apps -4. If ADB is enabled, the next step will pair — approve the prompt on the Shield -5. Optionally configure Jellyfin, receiver, power switch +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 @@ -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: - **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 `_async_handle_discovery_without_unique_id` contract: **any** existing entry 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 - **Receiver + Input** — for media-zone coordination - **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 diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..6685558 --- /dev/null +++ b/tests/test_readme.py @@ -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