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
@@ -352,8 +352,15 @@ list). A malformed row — no `=`, or an empty name — is rejected with an erro
|
||||
page rather than being half-saved.
|
||||
|
||||
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. (Driving that switch during
|
||||
playback is a separate, later change.)
|
||||
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`
|
||||
@@ -497,13 +504,22 @@ sweep could not run), while the Configure row forces one.
|
||||
|
||||
### Sources (the app list in a dropdown)
|
||||
|
||||
The media player's source list is your **Apps** map — the same map the Browse tree and
|
||||
app-name resolution use. With no map configured it falls back to a small built-in list
|
||||
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 a small built-in list
|
||||
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
|
||||
something to show. Discovery fills the **Configure** table, not this fallback: the
|
||||
dropdown stays exactly the map you saved, so what a card offers is always what you
|
||||
chose. 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
|
||||
@@ -516,7 +532,7 @@ entity: media_player.living_room_shield
|
||||
sensor: your map first, then the cached device label, then the raw package.
|
||||
- **`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
|
||||
is what the Browse tree publishes), names match case-insensitively, and a name 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
|
||||
@@ -537,10 +553,9 @@ The map is your own override, so two shapes are accepted rather than papered ove
|
||||
|
||||
- **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: the Browse tree still lists it under its own package id, and
|
||||
`media_player.select_source` accepts that id.
|
||||
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 and the Browse tree, but `app_name`, `source` and the Current App sensor
|
||||
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 to the raw package instead.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+40
-1
@@ -398,6 +398,10 @@ def install() -> None:
|
||||
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
|
||||
ha_const.STATE_PLAYING = "playing"
|
||||
ha_const.STATE_PAUSED = "paused"
|
||||
# t61: a receiver switch is read and switched by the media player, so the
|
||||
# two switch states have to exist with their real values.
|
||||
ha_const.STATE_ON = "on"
|
||||
ha_const.STATE_OFF = "off"
|
||||
ha_const.Platform = types.SimpleNamespace(
|
||||
MEDIA_PLAYER="media_player",
|
||||
SENSOR="sensor",
|
||||
@@ -609,8 +613,43 @@ def load_entity_modules() -> dict:
|
||||
}
|
||||
|
||||
|
||||
class FakeState:
|
||||
"""The fields entity code actually reads off an HA ``State`` (t61)."""
|
||||
|
||||
def __init__(self, entity_id, state, attributes=None):
|
||||
self.entity_id = entity_id
|
||||
self.state = state
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
|
||||
class FakeStates:
|
||||
"""A dict-backed stand-in for HA's StateMachine (t61).
|
||||
|
||||
Only the read shape entities use: ``get(entity_id)`` returns the state
|
||||
object, or None for an entity that does not exist — exactly what the real
|
||||
machine returns. ``set`` is a test's way of giving an entity a state.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._states: dict = {}
|
||||
|
||||
def set(self, entity_id, state, attributes=None):
|
||||
self._states[entity_id] = FakeState(entity_id, state, attributes)
|
||||
|
||||
def get(self, entity_id):
|
||||
return self._states.get(entity_id)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later."""
|
||||
"""Sized to entity unit tests: background tasks + no-op call_later.
|
||||
|
||||
``states`` is the real HomeAssistant shape (every hass has one), so entity
|
||||
code that reads a linked entity works unmodified; it is empty until a test
|
||||
sets something.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.states = FakeStates()
|
||||
|
||||
def async_create_task(self, coro):
|
||||
return asyncio.create_task(coro)
|
||||
|
||||
@@ -118,7 +118,11 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player._entity_tasks = set()
|
||||
player._channel_task = None
|
||||
player.async_write_ha_state = lambda: None
|
||||
player._read = lambda eid: None
|
||||
# t61: FakeHass carries the real `states` shape, so the entity's own `_read`
|
||||
# is the one under test — an entity no test set still reads as None.
|
||||
# t61: `__init__` gives the entity an (empty) power-sensor map; the harness
|
||||
# mirrors it so the attribute payload can be read here too.
|
||||
player._power_sensors = {}
|
||||
|
||||
# t41: nothing may leave this integration, so an HA service call is now the
|
||||
# failure signal rather than a supported tier. `served` stays empty unless a
|
||||
@@ -1455,9 +1459,10 @@ class TestFaithfulHaStubs:
|
||||
|
||||
|
||||
class TestBrowseMediaDirectory:
|
||||
"""P4-2c: the app directory root and its launch path."""
|
||||
"""t61: the HA media browser publishes NO media — apps are sources."""
|
||||
|
||||
async def test_root_lists_the_options_map(self, modules):
|
||||
async def test_the_browser_is_an_empty_root(self, modules):
|
||||
"""The user's report: apps must not appear in the media browser."""
|
||||
player, _s, _i = make_player(modules)
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
@@ -1466,53 +1471,56 @@ class TestBrowseMediaDirectory:
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert root.title == "Apps"
|
||||
assert root.can_play is False and root.can_expand is True
|
||||
ids = [(c.media_content_id, c.title) for c in root.children]
|
||||
assert ids == [
|
||||
("ar.tvplayer.tv", "TiviMate"),
|
||||
("com.netflix.ninja", "Netflix"),
|
||||
]
|
||||
assert all(c.media_content_type == "app" for c in root.children)
|
||||
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||
# media_content_id/-type at every level, the directory naming the class
|
||||
# of its children, and NO media_source `domain` (that parameter belongs
|
||||
# to media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids).
|
||||
assert root.children == [] # not one app row
|
||||
assert root.can_play is False
|
||||
assert root.can_expand is False # there is nothing to open
|
||||
assert root.title == "mdarcy" # the device, not a second app list
|
||||
# t37 D1: still a standard media_player.BrowseMedia root — explicit
|
||||
# class/type, NO media_source `domain` (that parameter belongs to
|
||||
# media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids and is not exportable from media_player at all).
|
||||
assert root.media_class == "directory"
|
||||
assert root.media_content_id == "apps"
|
||||
assert root.media_content_type == "apps"
|
||||
assert root.children_media_class == "app"
|
||||
assert root.media_content_id is None # no node to select
|
||||
assert root.children_media_class is None
|
||||
assert not hasattr(root, "domain")
|
||||
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||
# ...and it serialises into the payload the frontend consumes
|
||||
payload = root.as_dict()
|
||||
assert payload["media_content_id"] == "apps"
|
||||
assert payload["children_media_class"] == "app"
|
||||
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||
assert payload["children"][0]["media_content_type"] == "app"
|
||||
assert payload["children"][0]["can_play"] is True
|
||||
assert payload["children"] == []
|
||||
assert payload["can_expand"] is False
|
||||
|
||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||
player, _s, _i = make_player(modules) # no apps map configured
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
ids = [c.media_content_id for c in root.children]
|
||||
assert "ar.tvplayer.tv" in ids # TiviMate
|
||||
assert "com.github.damontecres.wholphin" in ids # Wholphin
|
||||
assert "com.netflix.ninja" in ids # Netflix
|
||||
assert "com.google.android.youtube.tv" in ids # YouTube
|
||||
|
||||
async def test_selection_launches_via_play_media_app(self, modules):
|
||||
async def test_apps_stay_reachable_through_the_sources(self, modules):
|
||||
"""What the browser no longer offers, source_list/select_source still do."""
|
||||
player, _s, _i = make_player(modules)
|
||||
protocol = player._manager._protocol
|
||||
player._entry.options["app_name_map"] = {
|
||||
"ar.tvplayer.tv": "TiviMate",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
|
||||
root = await player.async_browse_media()
|
||||
selected = root.children[0]
|
||||
|
||||
await player.async_play_media("app", selected.media_content_id)
|
||||
assert root.children == []
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
|
||||
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
|
||||
protocol = player._manager._protocol
|
||||
await player.async_select_source("Netflix")
|
||||
|
||||
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
|
||||
|
||||
async def test_any_rebrowse_id_stays_a_valid_empty_root(self, modules):
|
||||
"""A frontend that re-browses an id cannot make this crash or lie."""
|
||||
player, _s, _i = make_player(modules)
|
||||
|
||||
for content_type, content_id in (
|
||||
(None, None),
|
||||
("apps", "apps"),
|
||||
("app", "ar.tvplayer.tv"),
|
||||
("channel", "12"),
|
||||
):
|
||||
root = await player.async_browse_media(content_type, content_id)
|
||||
|
||||
assert root.children == []
|
||||
assert root.media_content_id is None
|
||||
|
||||
|
||||
class DownAdbPoller(FakeAdbPoller):
|
||||
|
||||
+35
-35
@@ -78,17 +78,20 @@ class TestSourceList:
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert "" not in player.source_list
|
||||
|
||||
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
|
||||
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
|
||||
def test_the_list_is_the_normalized_directory_exactly(self, modules):
|
||||
"""Same accessor, same order, same bytes — a blank entry excepted (t49).
|
||||
|
||||
t61 removed the browse tree that used to be the second consumer of this
|
||||
one accessor; the dropdown and the reverse lookup are still fed from it,
|
||||
so a row can only launch the app it shows.
|
||||
"""
|
||||
for app_map in (
|
||||
None, # the curated default
|
||||
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
|
||||
):
|
||||
player, _served = _player(modules, app_map=app_map)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player.source_list == [child.title for child in root.children]
|
||||
assert player.source_list == list(player._app_directory.values())
|
||||
|
||||
async def test_the_survivors_still_resolve_and_launch(self, modules):
|
||||
"""The filter must not cost a resolvable name (t49)."""
|
||||
@@ -279,6 +282,11 @@ class TestNormalizedDirectories:
|
||||
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
|
||||
name) and is pinned here, together with the promise that a well-formed map is
|
||||
untouched.
|
||||
|
||||
t61 left two surfaces instead of three: the Browse tree that used to be the
|
||||
second view is gone (apps are sources), so the pins below anchor the same
|
||||
policy on `_app_directory` — the shared accessor itself — and on
|
||||
`source_list`/`_source_package`, which are what a user can still reach.
|
||||
"""
|
||||
|
||||
BLANKED = {
|
||||
@@ -288,25 +296,21 @@ class TestNormalizedDirectories:
|
||||
"": "Ghost",
|
||||
}
|
||||
|
||||
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
|
||||
"""The Browse tree and the dropdown are the same, resolved offer."""
|
||||
def test_a_blank_entry_never_reaches_the_dropdown(self, modules):
|
||||
"""Blanks are dropped once, in the accessor every offer reads (t49)."""
|
||||
player, _served = _player(modules, app_map=dict(self.BLANKED))
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.title for child in root.children] == ["TiviMate"]
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert list(player._app_directory) == [TIVIMATE]
|
||||
assert list(player._app_directory.values()) == ["TiviMate"]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_blank_package_is_dropped_too(self, modules):
|
||||
"""An entry with no package is a child that cannot be launched."""
|
||||
def test_a_blank_package_is_dropped_too(self, modules):
|
||||
"""An entry with no package is a row that cannot be launched."""
|
||||
player, _served = _player(
|
||||
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert [child.media_content_id for child in root.children] == [TIVIMATE]
|
||||
assert list(player._app_directory) == [TIVIMATE]
|
||||
assert player.source_list == ["TiviMate"]
|
||||
|
||||
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
|
||||
@@ -315,11 +319,9 @@ class TestNormalizedDirectories:
|
||||
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# display and match key are one string, everywhere
|
||||
assert player.source_list == ["TiviMate", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
|
||||
assert list(player._app_directory.values()) == ["TiviMate", "Netflix"]
|
||||
# ...so every spelling the user may type finds the package
|
||||
assert player._source_package("TiviMate") == TIVIMATE
|
||||
assert player._source_package(" TiviMate ") == TIVIMATE
|
||||
@@ -335,13 +337,11 @@ class TestNormalizedDirectories:
|
||||
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
# both are still offered in the tree, each by its own package id
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Netflix"),
|
||||
("com.b", "Netflix"),
|
||||
]
|
||||
# t61: the directory keeps BOTH entries, each under its own package id;
|
||||
# the removal of the browse tree does not remove the reachability it
|
||||
# used to expose — select_source-by-id is that surface now.
|
||||
assert player._app_directory == {"com.a": "Netflix", "com.b": "Netflix"}
|
||||
assert player._source_package("com.b") == "com.b" # the shadowed package
|
||||
# the dropdown offers the name once, and resolves to the first entry
|
||||
assert player.source_list == ["Netflix"]
|
||||
assert player._source_package("Netflix") == "com.a"
|
||||
@@ -363,10 +363,11 @@ class TestNormalizedDirectories:
|
||||
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
|
||||
)
|
||||
|
||||
root = await player.async_browse_media()
|
||||
|
||||
assert player._app_directory == {
|
||||
TIVIMATE: "TiviMate Pro",
|
||||
"com.netflix.ninja": "Netflix",
|
||||
}
|
||||
assert player.source_list == ["TiviMate Pro", "Netflix"]
|
||||
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
|
||||
assert player._source_package("TiviMate Pro") == TIVIMATE
|
||||
|
||||
|
||||
@@ -386,12 +387,11 @@ class TestCaseInsensitiveNames:
|
||||
# one row, keeping the first spelling the user typed
|
||||
assert player.source_list == ["Dup"]
|
||||
|
||||
# ...and the Browse tree still holds both apps, each under its own id
|
||||
root = await player.async_browse_media()
|
||||
assert [(c.media_content_id, c.title) for c in root.children] == [
|
||||
("com.a", "Dup"),
|
||||
("com.b", "dup"),
|
||||
]
|
||||
# ...and the directory still holds both apps, each under its own id, so
|
||||
# the shadowed one stays reachable by its package id (t61: that is what
|
||||
# the removed browse tree used to publish)
|
||||
assert player._app_directory == {"com.a": "Dup", "com.b": "dup"}
|
||||
assert player._source_package("com.b") == "com.b"
|
||||
|
||||
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
|
||||
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""tests/test_receiver_switch.py — a plain switch as the Receiver (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 the playback entry
|
||||
points — ``turn_on``, ``play_media`` and ``select_source`` — ensure a
|
||||
``switch.*`` receiver is ON, and only when it is actually off.
|
||||
|
||||
The four states the contract names are all pinned here:
|
||||
|
||||
* switch off + playback starts → exactly one ``switch.turn_on``;
|
||||
* switch already on → NO call (idempotent, not a toggle);
|
||||
* switch unavailable/unknown/missing/failing → playback continues, nothing raises;
|
||||
* a ``media_player.*`` receiver → never switched; its behaviour is unchanged.
|
||||
|
||||
No real Shield, no real switch and no browser take part: the entity comes from
|
||||
the same stub harness every other entity test uses, the receiver's state is a
|
||||
value in ``FakeStates`` and the service call is recorded.
|
||||
"""
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from nsh.const import CONF_APP_NAME_MAP, CONF_RECEIVER_ENTITY
|
||||
from test_command_routing import make_player
|
||||
|
||||
TIVIMATE = "ar.tvplayer.tv"
|
||||
SOUNDBAR = "switch.soundbar"
|
||||
AVR = "media_player.avr"
|
||||
|
||||
|
||||
@pytest.fixture(name="modules", autouse=True)
|
||||
def scoped_modules():
|
||||
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
|
||||
snapshot = entity_stublib.snapshot_modules()
|
||||
entity_stublib.install()
|
||||
try:
|
||||
yield entity_stublib.load_entity_modules()
|
||||
finally:
|
||||
entity_stublib.restore_modules(snapshot)
|
||||
|
||||
|
||||
def _player(modules, *, receiver=SOUNDBAR, state=None, app_map=None):
|
||||
"""A player with a Receiver configured (a switch unless told otherwise)."""
|
||||
player, served, _intents = make_player(modules)
|
||||
player._entry.options[CONF_RECEIVER_ENTITY] = receiver
|
||||
if app_map is not None:
|
||||
player._entry.options[CONF_APP_NAME_MAP] = app_map
|
||||
if state is not None:
|
||||
player.hass.states.set(receiver, state)
|
||||
return player, served
|
||||
|
||||
|
||||
class TestTheSwitchReceiverIsEnsuredOn:
|
||||
"""Every playback entry point turns an off switch receiver on — once."""
|
||||
|
||||
async def test_play_media_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
# the ensure is the ONLY service call: no volume, no toggle, and the
|
||||
# app still launches (which is why the volume scripts are unnecessary)
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_turn_on_turns_an_off_receiver_on(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
|
||||
async def test_select_source_turns_an_off_receiver_on_once(self, modules):
|
||||
"""select_source dispatches internally, so the switch is read once."""
|
||||
player, served = _player(
|
||||
modules, state="off", app_map={TIVIMATE: "TiviMate"}
|
||||
)
|
||||
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_an_on_receiver_is_never_called(self, modules):
|
||||
"""Idempotent: every entry point may run, and NOT ONE call goes out."""
|
||||
player, served = _player(modules, state="on")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
await player.async_select_source("TiviMate")
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_no_volume_service_is_ever_aimed_at_the_receiver(self, modules):
|
||||
"""The user's point: a switch receiver means no volume scripts."""
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert [(domain, service) for domain, service, _data in served] == [
|
||||
("switch", "turn_on")
|
||||
]
|
||||
|
||||
|
||||
class TestAnUnusableReceiverNeverBreaksPlayback:
|
||||
"""A receiver we cannot read, or that fails, is logged — never raised."""
|
||||
|
||||
async def test_an_unavailable_switch_is_left_alone(self, modules):
|
||||
player, served = _player(modules, state="unavailable")
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == [] # never guessed at
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE] # still plays
|
||||
|
||||
async def test_an_unknown_state_is_not_guessed_at(self, modules):
|
||||
player, served = _player(modules, state="unknown")
|
||||
|
||||
await player.async_turn_on()
|
||||
|
||||
assert served == []
|
||||
|
||||
async def test_a_missing_entity_is_left_alone(self, modules):
|
||||
player, served = _player(modules) # the receiver is not in the machine
|
||||
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_a_failing_service_call_does_not_stop_playback(self, modules):
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
player, _served = _player(modules, state="off")
|
||||
|
||||
class _BrokenServices:
|
||||
async def async_call(self, domain, service, data=None, **kwargs):
|
||||
raise HomeAssistantError("the switch entity is gone")
|
||||
|
||||
player.hass.services = _BrokenServices()
|
||||
|
||||
await player.async_play_media("app", TIVIMATE) # must not raise
|
||||
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
|
||||
class TestAMediaPlayerReceiverIsUntouched:
|
||||
"""An AVR stays an AVR: input selection and enrichment, never a switch call."""
|
||||
|
||||
async def test_a_media_player_receiver_is_never_switched(self, modules):
|
||||
player, served = _player(modules, receiver=AVR, state="off")
|
||||
player.hass.states.set(AVR, "idle")
|
||||
|
||||
await player.async_turn_on()
|
||||
await player.async_play_media("app", TIVIMATE)
|
||||
|
||||
assert served == []
|
||||
assert player._manager._protocol.launch_calls == [TIVIMATE]
|
||||
|
||||
async def test_the_receiver_option_still_reaches_the_attributes(self, modules):
|
||||
from nsh.const import ATTR_MEDIA_DAG_RECEIVER_ENTITY
|
||||
|
||||
player, _served = _player(modules, receiver=AVR)
|
||||
|
||||
assert (
|
||||
player.extra_state_attributes[ATTR_MEDIA_DAG_RECEIVER_ENTITY] == AVR
|
||||
)
|
||||
|
||||
|
||||
class TestAnIdleShieldLeavesTheReceiverAlone:
|
||||
"""Switching the receiver on is a PLAYBACK action, not a synchronisation."""
|
||||
|
||||
async def test_reading_state_and_attributes_calls_nothing(self, modules):
|
||||
player, served = _player(modules, state="off")
|
||||
|
||||
assert player.state is not None # a state read
|
||||
assert isinstance(player.extra_state_attributes, dict) # what the frontend reads
|
||||
player._on_manager_update() # the connection manager pushed an update
|
||||
|
||||
assert served == []
|
||||
Reference in new issue
Block a user