From 25779a49a4a73a0bb8490faf0baf534abb981d24 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Apr 2022 08:24:17 +0200 Subject: [PATCH] Add slimproto integration (Squeezebox players) (#70444) Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen --- .coveragerc | 2 + CODEOWNERS | 2 + .../components/slimproto/__init__.py | 50 ++++ .../components/slimproto/config_flow.py | 26 +++ homeassistant/components/slimproto/const.py | 8 + .../components/slimproto/manifest.json | 10 + .../components/slimproto/media_player.py | 220 ++++++++++++++++++ .../components/slimproto/strings.json | 10 + .../components/slimproto/translations/en.json | 11 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/slimproto/__init__.py | 1 + tests/components/slimproto/conftest.py | 31 +++ .../components/slimproto/test_config_flow.py | 38 +++ 15 files changed, 416 insertions(+) create mode 100644 homeassistant/components/slimproto/__init__.py create mode 100644 homeassistant/components/slimproto/config_flow.py create mode 100644 homeassistant/components/slimproto/const.py create mode 100644 homeassistant/components/slimproto/manifest.json create mode 100644 homeassistant/components/slimproto/media_player.py create mode 100644 homeassistant/components/slimproto/strings.json create mode 100644 homeassistant/components/slimproto/translations/en.json create mode 100644 tests/components/slimproto/__init__.py create mode 100644 tests/components/slimproto/conftest.py create mode 100644 tests/components/slimproto/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 96d982f32bf..b7433ecf58a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1129,6 +1129,8 @@ omit = homeassistant/components/spotify/media_player.py homeassistant/components/spotify/system_health.py homeassistant/components/spotify/util.py + homeassistant/components/slimproto/__init__.py + homeassistant/components/slimproto/media_player.py homeassistant/components/squeezebox/__init__.py homeassistant/components/squeezebox/browse_media.py homeassistant/components/squeezebox/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index ec5bd45d7a3..c3405001f23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -917,6 +917,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 +/homeassistant/components/slimproto/ @marcelveldt +/tests/components/slimproto/ @marcelveldt /homeassistant/components/sma/ @kellerza @rklomp /tests/components/sma/ @kellerza @rklomp /homeassistant/components/smappee/ @bsmappee diff --git a/homeassistant/components/slimproto/__init__.py b/homeassistant/components/slimproto/__init__.py new file mode 100644 index 00000000000..96932e1e81f --- /dev/null +++ b/homeassistant/components/slimproto/__init__.py @@ -0,0 +1,50 @@ +"""SlimProto Player integration.""" +from __future__ import annotations + +from aioslimproto import SlimServer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +PLATFORMS = ["media_player"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + slimserver = SlimServer() + await slimserver.start() + + hass.data[DOMAIN] = slimserver + + # initialize platform(s) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + # setup event listeners + async def on_hass_stop(event: Event) -> None: + """Handle incoming stop event from Home Assistant.""" + await slimserver.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) + + return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_success: + await hass.data.pop(DOMAIN).stop() + return unload_success diff --git a/homeassistant/components/slimproto/config_flow.py b/homeassistant/components/slimproto/config_flow.py new file mode 100644 index 00000000000..7e2e96f74dc --- /dev/null +++ b/homeassistant/components/slimproto/config_flow.py @@ -0,0 +1,26 @@ +"""Config flow for SlimProto Player integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + + +class SlimProtoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SlimProto Player.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + # we have nothing to configure so simply create the entry + return self.async_create_entry(title=DEFAULT_NAME, data={}) diff --git a/homeassistant/components/slimproto/const.py b/homeassistant/components/slimproto/const.py new file mode 100644 index 00000000000..3b85de5d794 --- /dev/null +++ b/homeassistant/components/slimproto/const.py @@ -0,0 +1,8 @@ +"""Constants for SlimProto Player integration.""" + + +DOMAIN = "slimproto" + +DEFAULT_NAME = "SlimProto Player" + +PLAYER_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/slimproto/manifest.json b/homeassistant/components/slimproto/manifest.json new file mode 100644 index 00000000000..b8e00eb3f99 --- /dev/null +++ b/homeassistant/components/slimproto/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "slimproto", + "name": "SlimProto (Squeezebox players)", + "config_flow": true, + "iot_class": "local_push", + "documentation": "https://www.home-assistant.io/integrations/slimproto", + "requirements": ["aioslimproto==1.0.0"], + "codeowners": ["@marcelveldt"], + "after_dependencies": ["media_source"] +} diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py new file mode 100644 index 00000000000..af3eb693478 --- /dev/null +++ b/homeassistant/components/slimproto/media_player.py @@ -0,0 +1,220 @@ +"""MediaPlayer platform for SlimProto Player integration.""" +from __future__ import annotations + +import asyncio + +from aioslimproto.client import PlayerState, SlimClient +from aioslimproto.const import EventType, SlimEvent +from aioslimproto.server import SlimServer + +from homeassistant.components import media_source +from homeassistant.components.media_player import ( + BrowseMedia, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, +) +from homeassistant.components.media_player.browse_media import ( + async_process_play_media_url, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow + +from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT + +STATE_MAPPING = { + PlayerState.IDLE: STATE_IDLE, + PlayerState.PLAYING: STATE_PLAYING, + PlayerState.PAUSED: STATE_PAUSED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SlimProto MediaPlayer(s) from Config Entry.""" + slimserver: SlimServer = hass.data[DOMAIN] + added_ids = set() + + async def async_add_player(player: SlimClient) -> None: + """Add MediaPlayerEntity from SlimClient.""" + # we delay adding the player a small bit because the player name may be received + # just a bit after connect. This way we can create a device reg entry with the correct name + # the name will either be available within a few milliseconds after connect or not at all + # (its an optional data packet) + for _ in range(10): + if player.player_id not in player.name: + break + await asyncio.sleep(0.1) + async_add_entities([SlimProtoPlayer(slimserver, player)]) + + async def on_slim_event(event: SlimEvent) -> None: + """Handle player added/connected event.""" + if event.player_id in added_ids: + return + added_ids.add(event.player_id) + player = slimserver.get_player(event.player_id) + await async_add_player(player) + + # register listener for new players + config_entry.async_on_unload( + slimserver.subscribe(on_slim_event, EventType.PLAYER_CONNECTED) + ) + + # add all current items in controller + await asyncio.gather(*(async_add_player(player) for player in slimserver.players)) + + +class SlimProtoPlayer(MediaPlayerEntity): + """Representation of MediaPlayerEntity from SlimProto Player.""" + + _attr_should_poll = False + _attr_supported_features = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.BROWSE_MEDIA + ) + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__(self, slimserver: SlimServer, player: SlimClient) -> None: + """Initialize MediaPlayer entity.""" + self.slimserver = slimserver + self.player = player + self._attr_unique_id = player.player_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.player.player_id)}, + manufacturer=DEFAULT_NAME, + model=self.player.device_model or self.player.device_type, + name=self.player.name, + hw_version=self.player.firmware, + ) + # PiCore player has web interface + if "-pCP" in self.player.firmware: + self._attr_device_info[ + "configuration_url" + ] = f"http://{self.player.device_address}" + self.update_attributes() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.update_attributes() + self.async_on_remove( + self.slimserver.subscribe( + self._on_slim_event, + ( + EventType.PLAYER_UPDATED, + EventType.PLAYER_CONNECTED, + EventType.PLAYER_DISCONNECTED, + EventType.PLAYER_NAME_RECEIVED, + EventType.PLAYER_RPC_EVENT, + ), + player_filter=self.player.player_id, + ) + ) + + @property + def available(self) -> bool: + """Return availability of entity.""" + return self.player.connected + + @property + def state(self) -> str: + """Return current state.""" + if not self.player.powered: + return STATE_OFF + return STATE_MAPPING[self.player.state] + + @callback + def update_attributes(self) -> None: + """Handle player updates.""" + self._attr_name = self.player.name + self._attr_volume_level = self.player.volume_level / 100 + self._attr_media_position = self.player.elapsed_seconds + self._attr_media_position_updated_at = utcnow() + self._attr_media_content_id = self.player.current_url + self._attr_media_content_type = "music" + + async def async_media_play(self) -> None: + """Send play command to device.""" + await self.player.play() + + async def async_media_pause(self) -> None: + """Send pause command to device.""" + await self.player.pause() + + async def async_media_stop(self) -> None: + """Send stop command to device.""" + await self.player.stop() + + async def async_set_volume_level(self, volume: float) -> None: + """Send new volume_level to device.""" + volume = round(volume * 100) + await self.player.volume_set(volume) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + await self.player.mute(mute) + + async def async_turn_on(self) -> None: + """Turn on device.""" + await self.player.power(True) + + async def async_turn_off(self) -> None: + """Turn off device.""" + await self.player.power(False) + + async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: + """Send the play_media command to the media player.""" + to_send_media_type: str | None = media_type + # Handle media_source + if media_source.is_media_source_id(media_id): + sourced_media = await media_source.async_resolve_media(self.hass, media_id) + media_id = sourced_media.url + to_send_media_type = sourced_media.mime_type + + if to_send_media_type and not to_send_media_type.startswith("audio/"): + to_send_media_type = None + media_id = async_process_play_media_url(self.hass, media_id) + + await self.player.play_url(media_id, mime_type=to_send_media_type) + + async def async_browse_media( + self, media_content_type=None, media_content_id=None + ) -> BrowseMedia: + """Implement the websocket media browsing helper.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) + + async def _on_slim_event(self, event: SlimEvent) -> None: + """Call when we receive an event from SlimProto.""" + if event.type == EventType.PLAYER_CONNECTED: + # player reconnected, update our player object + self.player = self.slimserver.get_player(event.player_id) + if event.type == EventType.PLAYER_RPC_EVENT: + # rpc event from player such as a button press, + # forward on the eventbus for others to handle + dev_id = self.registry_entry.device_id if self.registry_entry else None + evt_data = { + **event.data, + "entity_id": self.entity_id, + "device_id": dev_id, + } + self.hass.bus.async_fire(PLAYER_EVENT, evt_data) + return + self.update_attributes() + self.async_write_ha_state() diff --git a/homeassistant/components/slimproto/strings.json b/homeassistant/components/slimproto/strings.json new file mode 100644 index 00000000000..8eb261b58fc --- /dev/null +++ b/homeassistant/components/slimproto/strings.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": {} + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/slimproto/translations/en.json b/homeassistant/components/slimproto/translations/en.json new file mode 100644 index 00000000000..fcefa6de190 --- /dev/null +++ b/homeassistant/components/slimproto/translations/en.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "user": { + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 10c9daa61be..710d97f3c34 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -305,6 +305,7 @@ FLOWS = { "sia", "simplisafe", "sleepiq", + "slimproto", "sma", "smappee", "smart_meter_texas", diff --git a/requirements_all.txt b/requirements_all.txt index fc5335e3a57..0bbb5fcf7db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,6 +243,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.slimproto +aioslimproto==1.0.0 + # homeassistant.components.steamist aiosteamist==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f33c5da6ebe..27069126ca0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,6 +209,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.slimproto +aioslimproto==1.0.0 + # homeassistant.components.steamist aiosteamist==0.3.1 diff --git a/tests/components/slimproto/__init__.py b/tests/components/slimproto/__init__.py new file mode 100644 index 00000000000..9119292b888 --- /dev/null +++ b/tests/components/slimproto/__init__.py @@ -0,0 +1 @@ +"""Tests for the SlimProto Player integration.""" diff --git a/tests/components/slimproto/conftest.py b/tests/components/slimproto/conftest.py new file mode 100644 index 00000000000..faf91ea27d1 --- /dev/null +++ b/tests/components/slimproto/conftest.py @@ -0,0 +1,31 @@ +"""Fixtures for the SlimProto Player integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.slimproto.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Radios", + domain=DOMAIN, + data={}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.slimproto.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/slimproto/test_config_flow.py b/tests/components/slimproto/test_config_flow.py new file mode 100644 index 00000000000..0c0c843f0b5 --- /dev/null +++ b/tests/components/slimproto/test_config_flow.py @@ -0,0 +1,38 @@ +"""Test the SlimProto Player config flow.""" +from unittest.mock import AsyncMock + +from homeassistant.components.slimproto.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == DEFAULT_NAME + assert result.get("data") == {} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test abort if SlimProto Player is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "single_instance_allowed"