Add slimproto integration (Squeezebox players) (#70444)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Marcel van der Veldt
2022-04-27 08:24:17 +02:00
committed by GitHub
parent 5c1be2f99d
commit 25779a49a4
15 changed files with 416 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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={})

View File

@ -0,0 +1,8 @@
"""Constants for SlimProto Player integration."""
DOMAIN = "slimproto"
DEFAULT_NAME = "SlimProto Player"
PLAYER_EVENT = f"{DOMAIN}_event"

View File

@ -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"]
}

View File

@ -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()

View File

@ -0,0 +1,10 @@
{
"config": {
"step": {
"user": {}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
}
}

View File

@ -0,0 +1,11 @@
{
"config": {
"abort": {
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"step": {
"user": {
}
}
}
}

View File

@ -305,6 +305,7 @@ FLOWS = {
"sia",
"simplisafe",
"sleepiq",
"slimproto",
"sma",
"smappee",
"smart_meter_texas",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the SlimProto Player integration."""

View File

@ -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

View File

@ -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"