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:
1 parent
c884c7e1ab
commit
431b7cd8f4
8 files changed
+448
-133
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user