feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)

Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
This commit is contained in:
engineer committed 2026-10-12 08:34:53 +01:00
1 parent c884c7e1ab
commit 431b7cd8f4
8 files changed
+448 -133

No files matched your search

+116 -43
View File
@@ -78,9 +78,9 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
@@ -130,6 +130,11 @@ SERVICE_TO_STATE = {
"media_stop": MediaPlayerState.IDLE,
}
#: t61: the domain a receiver must be in for playback to ENSURE it is on. A
#: receiver in any other domain (an AVR media_player) keeps its own semantics —
#: input selection and attribute enrichment — and is never switched here.
RECEIVER_SWITCH_DOMAIN = "switch"
_BASE_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
@@ -828,8 +833,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _app_directory(self) -> dict:
"""Launchable apps: the user's map, else the curated default (P4-1).
ONE source for the browse tree, the source list and the reverse lookup, so
the dropdown can never offer a name the launch path cannot resolve.
ONE source for the source list and the reverse lookup (t61 removed the
browse tree that used to be the third consumer), so the dropdown can
never offer a name the launch path cannot resolve.
The map is hand-editable, so it is NORMALIZED here — once, so that every
consumer sees the same thing (t51):
@@ -839,9 +845,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
"TiviMate", instead of being shown padded and missing every lookup;
* an entry whose name — or whose package — is blank/whitespace-only is
dropped, so no surface can offer a child with no package behind it or a
name no launch can reach (the t48 invariant, extended to the tree);
* two packages normalizing to the same name are BOTH KEPT — the browse
tree reaches each by its own package id, so neither app is hidden — while
name no launch can reach (the t48 invariant, extended to the table);
* two packages normalizing to the same name are BOTH KEPT — each stays
reachable by its own package id, which ``select_source`` accepts (t61:
the browse tree that used to publish those ids is gone) — while
the FIRST in map order wins the name: that is the single entry
source_list offers and the package select_source resolves. Map order is
the user's order, so the winner does not move between rebuilds. "The same
@@ -870,8 +877,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
Directory order (the user's map order, or the curated list), so rebuilding
the page does not shuffle it. Blanks are already gone and names already
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
one accessor so the dropdown, the browse tree and the reverse lookup cannot
disagree.
one accessor so the dropdown and the reverse lookup cannot disagree.
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
first spelling the user typed. Exact duplicates were already collapsed, but
@@ -879,8 +885,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
of them to the first entry, so the second row launched the wrong app. One
row per name means a row can only launch the app it shows. The shadowed
package is not lost: the browse tree still lists it under its own id, and
select_source accepts that id.
package is not lost: ``select_source`` accepts its own package id.
"""
seen: set[str] = set()
offered: list[str] = []
@@ -1212,8 +1217,67 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self.hass, self._entry.options, command, variables
)
async def _ensure_receiver_on(self) -> None:
"""Turn a SWITCH receiver on before playback — only when it is off (t61).
The user's request, verbatim: "it would be nice if a receiver could just
be a switch, I don't need my volume scripts then because the receiver
portion could just make sure the receiver is on if it's a switch". So a
``switch.*`` receiver is ENSURED ON by the playback entry points
(:meth:`async_turn_on`, :meth:`async_play_media`,
:meth:`async_select_source`) and nothing else happens to it: no volume,
no toggle, no other switch semantics.
Deliberately narrow, in four ways:
* only a ``switch.*`` receiver is touched — a ``media_player.*``
receiver keeps its behaviour exactly;
* only when the switch reports ``off``. An ``on`` switch issues no
service call at all, so this is idempotent — a state write, a poll or
a second entry point in the same action never re-calls it. A state we
cannot read (missing entity, ``unavailable``, ``unknown``) is left
alone rather than guessed at, with a debug line saying so;
* never called from a state read, a poll, a listener or attribute
rendering, so a receiver that is off while the Shield is idle stays
off — this is a playback action, not a synchronisation;
* a service failure (the entity vanished between the read and the call)
is logged, not raised: a receiver that cannot be switched on must not
stop the Shield from playing.
"""
entity_id = self._receiver_entity
if not entity_id or not entity_id.startswith(f"{RECEIVER_SWITCH_DOMAIN}."):
return
state = self._read(entity_id)
if state is None:
_LOGGER.debug(
"receiver %s is not in the state machine — not switching it",
entity_id,
)
return
if state.state != STATE_OFF:
if state.state != STATE_ON:
_LOGGER.debug(
"receiver %s reports %r — not switching it", entity_id, state.state
)
return
try:
await self.hass.services.async_call(
RECEIVER_SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
except HomeAssistantError as err:
_LOGGER.debug("receiver %s could not be turned on: %s", entity_id, err)
async def async_turn_on(self):
"""Wake the Shield (only when it reports OFF) — or run the turn_on script."""
"""Wake the Shield (only when it reports OFF) — or run the turn_on script.
A ``switch.*`` receiver is ensured on first (t61): the receiver is where
the Shield's sound comes out, so a playback entry point that wakes the
Shield wakes it too — and only when it is actually off.
"""
await self._ensure_receiver_on()
if await self._script_served("turn_on"):
return
await self._power_key(send_when_on=False)
@@ -1267,8 +1331,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if not wanted:
return None
if wanted in directory:
# an automation may hand us the package directly; that is what the
# browse tree publishes as media_content_id
# an automation may hand us the package directly; that is the id
# this table is keyed by (t61: the browse tree that used to publish
# it is gone, the id itself is unchanged)
return wanted
folded = wanted.casefold()
for package, name in directory.items():
@@ -1285,7 +1350,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
directory the source list came from and launched over the shared
``play_media("app", …)`` path, so a universal-media-player replacement gets
the native behaviour. An unknown name is an error, never a silent no-op.
A ``switch.*`` receiver is ensured on first (t61), before the script: a
source selection is the start of playback, and it dispatches internally
so the switch is looked at exactly once per user action.
"""
await self._ensure_receiver_on()
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
return
package = self._source_package(source)
@@ -1295,7 +1365,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
translation_key="unknown_source",
translation_placeholders={"source": str(source)},
)
await self.async_play_media(MediaType.APP, package)
await self._dispatch_play_media(MediaType.APP, package)
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
@@ -1374,45 +1444,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Root lists the apps map (or the curated default) → play_media(app).
"""The media browser is EMPTY on purpose — apps live in the sources (t61).
t37 D1: the standard media_player tree, exactly as core's own
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
homeassistant.components.media_player, every node carrying an explicit
media_content_id/media_content_type, and the directory naming the
class of its children. ``BrowseMediaSource`` only ever existed in
homeassistant.components.media_source.models (t30 G18's domain
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
so an app-directory node must never claim one — and the import above
removed a name media_player has not exported for a long time, which
made every real browse raise.
The user's report, verbatim: "Media Browser shows Apps. To me it should
probably be integrated with the home assistant media display, keep
select sources for apps". They are right about the surface: HA's media
browser is for BROWSABLE media, while those rows are SOURCES. They stay
where a source belongs — ``source_list``/``source``, the source
dropdown, ``select_source`` and ``play_media("app", …)`` — and that
offer is complete.
So this returns a valid, standard ``media_player.BrowseMedia`` root with
NO children rather than a second, inconsistent copy of the app list:
``BrowseMedia`` (never ``BrowseMediaSource``, which media_player has not
exported since before t37 D1), the device as the root title, a directory
class, and no content id. There is no node to select, so no dead entry
can be offered and a click can never reach a row that cannot launch. An
integration with genuinely browsable media would publish it here; this
one has none.
"""
app_map = self._app_directory
children = [
BrowseMedia(
title=name,
media_class=MediaClass.APP,
media_content_type=MediaType.APP,
media_content_id=pkg,
can_play=True,
can_expand=False,
)
for pkg, name in app_map.items()
]
return BrowseMedia(
title="Apps",
title=self._entry.title or "NVIDIA Shield",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.APPS,
media_content_id="apps",
children_media_class=MediaClass.APP,
media_content_id=None,
can_play=False,
can_expand=True,
children=children,
can_expand=False,
children=[],
)
# ── play_media (§7): channel digits, app/url launch ──
async def async_play_media(self, media_type: str, media_id: str) -> None:
"""HA's ``play_media`` entry point — ensure the receiver, then dispatch.
The receiver ensure (t61) lives on the PUBLIC path only, so an internal
caller (:meth:`async_select_source`) dispatches without a second look at
the same switch inside one user action.
"""
await self._ensure_receiver_on()
await self._dispatch_play_media(media_type, media_id)
async def _dispatch_play_media(self, media_type: str, media_id: str) -> None:
"""Channel selection by digit number; app/url launches (C-LAUNCH).
Channel path replicates HA core androidtv_remote exactly: digits only,
+3 -3
View File
@@ -53,7 +53,7 @@
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
@@ -169,7 +169,7 @@
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
@@ -221,7 +221,7 @@
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off.",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
@@ -53,7 +53,7 @@
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch.",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
@@ -169,7 +169,7 @@
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
@@ -221,7 +221,7 @@
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off.",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"