Просмотр исходного кода

feat: add media_player entity support

Issue #1287

- add Ekaza MiniPad control panel which uses media_player

Issue #5418
Jason Rumney 1 день назад
Родитель
Сommit
4faeed9801

+ 18 - 0
custom_components/tuya_local/devices/README.md

@@ -741,6 +741,24 @@ no information will be available about which specific credential was used to unl
 - **set_unlock_code** (optional, base64): a dp that allows setting the 8 digit code at the same time as it is used in code_unlock, so the user does not need to enter an 8 digit number. This corresponds in the Tuya info to `remote_no_pd_setkey` and has a specific format. If this is supplied, the integration will simultaneously set a random code in slot 7, and use it to unlock the lock, so the user does not need to provide any code.
 - **jammed** (optional, boolean): a dp to signal that the lock is jammed.
 
+### `media_player`
+
+- **switch** (optional, boolean): a switch-like dp to toggle power on and off
+- **volume** (optional, number 0.0 - 1.0): a dp to control the volume level
+- **mute** (optional, boolean): a switch-like dp to mute and unmute the audio
+- **source** (optional, string): a dp to select the source. A mapping of values is required to let HA know the sources that are available for the user to select.
+- **playback_state** (optional, string): a read-only dp that reports the current playback state (states must be valid MediaPlayerState values). If not provided, the integration will try to reverse engineer the state based on `play`, `pause`, `power` dps
+- **play** (optional, boolean): a button-like dp to start playback
+- **pause** (optional, boolean): a button-like dp to pause playback
+- **prev** (optional, boolean): a button-like dp to jump to the previous track, or start of the current track (behaviour may vary depending on the device)
+- **next** (optional, boolean): a button-like dp to jump to the next track
+- **stop** (optional, boolean): a button-like dp to stop playback. Unlike `pause`, a subsequent `play` will not resume from the position it was stopped at.
+- **seek_position** (optional, integer): a dp to seek to the specified position within the current track
+- **clear_playlist** (optional, boolean): a button-like dp to clear the current playlist
+- **shuffle** (optional, boolean): a switch-like dp to control whether to shuffle the playlist
+- **repeat** (optional, string): a dp to control the repeat mode. Valid RepeatMode values are ["off", "one", "all"]
+- **sound_mode** (optional, string): a dp to select the sound mode. A mapping of values is required to let HA know which modes are available for the user to select.
+
 ### `number`
 - **value** (required, number): a dp to control the number that is set.
 - **unit** (optional, string): a dp that reports the units returned by the number.

+ 116 - 0
custom_components/tuya_local/devices/ekaza_minipad_controlpanel.yaml

@@ -0,0 +1,116 @@
+name: Control panel
+# products:
+#   - id: UNKNOWN
+#     manufacturer: Ekaza
+#     model: MiniPad
+entities:
+  - entity: switch
+    translation_key: switch_x
+    translation_placeholders:
+      x: "1"
+    category: config
+    dps:
+      - id: 16
+        type: boolean
+        name: switch
+  - entity: switch
+    translation_key: switch_x
+    translation_placeholders:
+      x: "2"
+    category: config
+    dps:
+      - id: 17
+        type: boolean
+        name: switch
+  - entity: media_player
+    class: speaker
+    dps:
+      - id: 80
+        type: integer
+        name: volume
+        range:
+          min: 0
+          max: 100
+        mapping:
+          - scale: 100
+      - id: 82
+        type: boolean
+        optional: true
+        name: play
+      - id: 83
+        type: string
+        optional: true
+        name: source
+        mapping:
+          - dps_val: smart_speaker
+            value: Smart speaker
+          - dps_val: bluetooth
+            value: Bluetooth
+      - id: 85
+        type: boolean
+        optional: true
+        name: prev
+      - id: 86
+        type: boolean
+        optional: true
+        name: next
+      - id: 87
+        type: string
+        optional: true
+        name: shuffle
+        mapping:
+          - dps_val: random
+            value: true
+          - dps_val: order
+            value: false
+          - value: false
+            hidden: true
+      - id: 87
+        type: string
+        optional: true
+        name: repeat
+        mapping:
+          - dps_val: repeat_all
+            value: all
+          - dps_val: repeat_one
+            value: one
+          - dps_val: order
+            value: "off"
+          - value: "off"
+            hidden: true
+  - entity: switch
+    name: Microphone
+    category: config
+    dps:
+      - id: 81
+        type: boolean
+        name: switch
+  - entity: switch
+    name: Bluetooth
+    icon: "mdi:bluetooth"
+    category: config
+    dps:
+      - id: 84
+        type: boolean
+        name: switch
+  - entity: text
+    name: Alarm clock
+    category: config
+    icon: "mdi:alarm"
+    hidden: true
+    dps:
+      - id: 88
+        type: string
+        optional: true
+        name: value
+  - entity: text
+    name: Alert
+    category: config
+    icon: "mdi:message-alert"
+    hidden: true
+    dps:
+      - id: 89
+        type: string
+        optional: true
+        name: value
+# dps 90-99 are related to Alexa pairing, and not exposed here

+ 267 - 0
custom_components/tuya_local/media_player.py

@@ -0,0 +1,267 @@
+"""
+Implementation of the Tuya media player devices
+"""
+
+import asyncio
+import logging
+
+from homeassistant.components.media_player import (
+    MediaPlayerEntity,
+    MediaPlayerEntityFeature,
+    MediaPlayerState,
+)
+from .device import TuyaLocalDevice
+from .entity import TuyaLocalEntity
+from .helpers.config import async_tuya_setup_platform
+from .helpers.device_config import TuyaEntityConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the Tuya Local media player platform."""
+    config = {**config_entry.data, **config_entry.options}
+    await async_tuya_setup_platform(
+        hass,
+        async_add_entities,
+        config,
+        "media_player",
+        TuyaLocalMediaPlayer,
+    )
+
+
+class TuyaLocalMediaPlayer(TuyaLocalEntity, MediaPlayerEntity):
+    """Representation of a Tuya Local media player device."""
+
+    def __init__(self, device: TuyaLocalDevice, config: TuyaEntityConfig):
+        """Initialize the media player device."""
+        super().__init__()
+        dps_map = self._init_begin(device, config)
+        self._power_dp = dps_map.pop("switch", None)
+        self._volume_dp = dps_map.pop("volume", None)
+        self._mute_dp = dps_map.pop("mute", None)
+        self._source_dp = dps_map.pop("source", None)
+        self._state_dp = dps_map.pop("playback_state", None)
+        self._play_dp = dps_map.pop("play", None)
+        self._pause_dp = dps_map.pop("pause", None)
+        self._prev_dp = dps_map.pop("prev", None)
+        self._next_dp = dps_map.pop("next", None)
+        self._stop_dp = dps_map.pop("stop", None)
+        self._seek_dp = dps_map.pop("seek_position", None)
+        self._clear_playlist_dp = dps_map.pop("clear_playlist", None)
+        self._shuffle_dp = dps_map.pop("shuffle", None)
+        self._repeat_dp = dps_map.pop("repeat", None)
+        self._sound_mode_dp = dps_map.pop("sound_mode", None)
+        self._init_end(dps_map)
+
+        self._support_flags = MediaPlayerEntityFeature(0)
+        if self._pause_dp:
+            self._support_flags |= MediaPlayerEntityFeature.PAUSE
+        if self._seek_dp:
+            self._support_flags |= MediaPlayerEntityFeature.SEEK
+        if self._volume_dp:
+            self._support_flags |= MediaPlayerEntityFeature.VOLUME_SET
+            if self._volume_dp.step is not None:
+                self._support_flags |= MediaPlayerEntityFeature.VOLUME_STEP
+        if self._mute_dp:
+            self._support_flags |= MediaPlayerEntityFeature.VOLUME_MUTE
+        if self._prev_dp:
+            self._support_flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK
+        if self._next_dp:
+            self._support_flags |= MediaPlayerEntityFeature.NEXT_TRACK
+        if self._power_dp:
+            self._support_flags |= MediaPlayerEntityFeature.TURN_ON
+            self._support_flags |= MediaPlayerEntityFeature.TURN_OFF
+        # PLAY_MEDIA for playing arbitrary media
+        if self._source_dp:
+            self._support_flags |= MediaPlayerEntityFeature.SELECT_SOURCE
+        if self._stop_dp:
+            self._support_flags |= MediaPlayerEntityFeature.STOP
+        if self._clear_playlist_dp:
+            self._support_flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST
+        if self._play_dp:
+            self._support_flags |= MediaPlayerEntityFeature.PLAY
+        if self._shuffle_dp:
+            self._support_flags |= MediaPlayerEntityFeature.SHUFFLE_SET
+        if self._repeat_dp:
+            self._support_flags |= MediaPlayerEntityFeature.REPEAT_SET
+        if self._sound_mode_dp:
+            self._support_flags |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
+        # BROWSE_MEDIA for browsing media on the device
+        # GROUPING for grouping multiple media players together
+        # MEDIA_ANNOUNCE for sending TTS to the device
+        # MEDIA_ENQUEUE for adding media to the queue
+        # SEARCH_MEDIA for searching media on the device
+
+    def state(self):
+        """Return the state of the media player."""
+        if self._state_dp:
+            return self._state_dp.get_value(self._device)
+        elif self._play_dp and self._play_dp.get_value(self._device):
+            return MediaPlayerState.PLAYING
+        elif self._pause_dp and self._pause_dp.get_value(self._device):
+            return MediaPlayerState.PAUSED
+        elif self._power_dp and not self._power_dp.get_value(self._device):
+            return MediaPlayerState.OFF
+        elif self._power_dp and self._power_dp.get_value(self._device):
+            return MediaPlayerState.ON
+        return None
+
+    def volume_level(self):
+        """Return the volume level of the media player (0..1)."""
+        if self._volume_dp:
+            return self._volume_dp.get_value(self._device)
+        return None
+
+    def volume_step(self):
+        """Return the volume step of the media player."""
+        if self._volume_dp:
+            return self._volume_dp.step(self._device)
+        return None
+
+    def is_volume_muted(self):
+        """Return True if the volume is muted."""
+        if self._mute_dp:
+            return self._mute_dp.get_value(self._device)
+        return None
+
+    def source(self):
+        """Return the current input source of the media player."""
+        if self._source_dp:
+            return self._source_dp.get_value(self._device)
+        return None
+
+    def source_list(self):
+        """Return the list of available input sources of the media player."""
+        if self._source_dp:
+            return self._source_dp.values(self._device)
+        return None
+
+    def sound_mode(self):
+        """Return the current sound mode of the media player."""
+        if self._sound_mode_dp:
+            return self._sound_mode_dp.get_value(self._device)
+        return None
+
+    def sound_mode_list(self):
+        """Return the list of available sound modes of the media player."""
+        if self._sound_mode_dp:
+            return self._sound_mode_dp.values(self._device)
+        return None
+
+    def shuffle(self):
+        """Return the current shuffle state of the media player."""
+        if self._shuffle_dp:
+            return self._shuffle_dp.get_value(self._device)
+        return None
+
+    def repeat(self):
+        """Return the current repeat state of the media player."""
+        if self._repeat_dp:
+            return self._repeat_dp.get_value(self._device)
+        return None
+
+    async def async_turn_on(self):
+        """Turn on the media player."""
+        if self._power_dp:
+            await self._power_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_turn_off(self):
+        """Turn off the media player."""
+        if self._power_dp:
+            await self._power_dp.async_set_value(self._device, False)
+        else:
+            raise NotImplementedError()
+
+    async def async_mute_volume(self, mute):
+        """Mute the volume."""
+        if self._mute_dp:
+            await self._mute_dp.async_set_value(self._device, mute)
+        else:
+            raise NotImplementedError()
+
+    async def async_set_volume_level(self, volume):
+        """Set the volume level."""
+        if self._volume_dp:
+            await self._volume_dp.async_set_value(self._device, volume)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_play(self):
+        """Send play command."""
+        if self._play_dp:
+            await self._play_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_pause(self):
+        """Send pause command."""
+        if self._pause_dp:
+            await self._pause_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_stop(self):
+        """Send stop command."""
+        if self._stop_dp:
+            await self._stop_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_previous_track(self):
+        """Send previous track command."""
+        if self._prev_dp:
+            await self._prev_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_next_track(self):
+        """Send next track command."""
+        if self._next_dp:
+            await self._next_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_media_seek(self, position):
+        """Seek to a specific position in the media."""
+        if self._seek_dp:
+            await self._seek_dp.async_set_value(self._device, position)
+        else:
+            raise NotImplementedError()
+
+    async def async_select_source(self, source):
+        """Select input source."""
+        if self._source_dp:
+            await self._source_dp.async_set_value(self._device, source)
+        else:
+            raise NotImplementedError()
+
+    async def async_select_sound_mode(self, sound_mode):
+        """Select sound mode."""
+        if self._sound_mode_dp:
+            await self._sound_mode_dp.async_set_value(self._device, sound_mode)
+        else:
+            raise NotImplementedError()
+
+    async def async_clear_playlist(self):
+        """Clear the playlist."""
+        if self._clear_playlist_dp:
+            await self._clear_playlist_dp.async_set_value(self._device, True)
+        else:
+            raise NotImplementedError()
+
+    async def async_set_shuffle(self, shuffle):
+        """Set shuffle mode."""
+        if self._shuffle_dp:
+            await self._shuffle_dp.async_set_value(self._device, shuffle)
+        else:
+            raise NotImplementedError()
+
+    async def async_set_repeat(self, repeat):
+        """Set repeat mode [all|one|off]."""
+        if self._repeat_dp:
+            await self._repeat_dp.async_set_value(self._device, repeat)
+        else:
+            raise NotImplementedError()

+ 21 - 0
tests/test_device_config.py

@@ -142,6 +142,7 @@ ENTITY_SCHEMA = vol.Schema(
                 "lawn_mower",
                 "light",
                 "lock",
+                "media_player",
                 "number",
                 "remote",
                 "select",
@@ -261,6 +262,26 @@ KNOWN_DPS = {
             "jammed",
         ],
     },
+    "media_player": {
+        "required": [],
+        "optional": [
+            "switch",
+            "volume",
+            "mute",
+            "source",
+            "playback_state",
+            "play",
+            "pause",
+            "prev",
+            "next",
+            "stop",
+            "seek_position",
+            "clear_playlist",
+            "shuffle",
+            "repeat",
+            "sound_mode",
+        ],
+    },
     "number": {
         "required": ["value"],
         "optional": ["unit", "minimum", "maximum", "decimal"],

+ 128 - 0
tests/test_media_player.py

@@ -0,0 +1,128 @@
+"""Tests for the media_player entity."""
+
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from homeassistant.components.media_player import MediaPlayerState
+
+from custom_components.tuya_local.const import (
+    CONF_DEVICE_ID,
+    CONF_PROTOCOL_VERSION,
+    CONF_TYPE,
+    DOMAIN,
+)
+from custom_components.tuya_local.media_player import (
+    TuyaLocalMediaPlayer,
+    async_setup_entry,
+)
+
+from .helpers import mock_device
+
+
+@pytest.mark.asyncio
+async def test_init_entry(hass):
+    """Test the initialisation."""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "ekaza_minipad_controlpanel",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+
+    await async_setup_entry(hass, entry, m_add_entities)
+    assert (
+        type(hass.data[DOMAIN]["dummy"]["media_player_speaker"]) is TuyaLocalMediaPlayer
+    )
+    m_add_entities.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_device_has_no_media_player(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "smartplugv1",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False, "Expected async_setup_entry to raise a ValueError"
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_init_entry_fails_if_config_is_missing(hass):
+    """Test initialisation when device has no matching entity"""
+    entry = MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_TYPE: "non_existing",
+            CONF_DEVICE_ID: "dummy",
+            CONF_PROTOCOL_VERSION: "auto",
+        },
+    )
+    # although async, the async_add_entities function passed to
+    # async_setup_entry is called truly asynchronously. If we use
+    # AsyncMock, it expects us to await the result.
+    m_add_entities = Mock()
+    m_device = AsyncMock()
+
+    hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+    try:
+        await async_setup_entry(hass, entry, m_add_entities)
+        assert False, "Expected async_setup_entry to raise a ValueError"
+    except ValueError:
+        pass
+    m_add_entities.assert_not_called()
+
+
+# Most features are simple mappings to dps values, but state can be more complex
+class TestMediaPlayerState:
+    """Test the state property of the media_player entity."""
+
+    @pytest.mark.asyncio
+    async def async_test_state(self, hass, mocker):
+        """Test the state property."""
+        tuya_device = mocker.MagicMock()
+        dps = {"82": True}
+        entry = MockConfigEntry(
+            domain=DOMAIN,
+            data={
+                CONF_TYPE: "ekaza_minipad_controlpanel",
+                CONF_DEVICE_ID: "dummy",
+                CONF_PROTOCOL_VERSION: "auto",
+            },
+        )
+        m_add_entities = Mock()
+        m_device = mock_device(dps, mocker)
+
+        hass.data[DOMAIN] = {"dummy": {"device": m_device}}
+
+        await async_setup_entry(hass, entry, m_add_entities)
+        media_player = hass.data[DOMAIN]["dummy"]["media_player_speaker"]
+
+        # Test that the state is correct when the device is playing
+        assert media_player.state == MediaPlayerState.PLAYING