mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Add roon media player integration (#37553)
* Import roon code. * Fix flake8/pylint issues. * Fix lint issues, extend timeout, change contact infomation. * Add new files to .coveragerc * Make file executable. * Fix problem with integration not working after initial creation. * Improve logic unavailable players by caching data. * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update review suggestions * Rremove custom play action script. * Add test requirements. * Tidy manifest. * Missed fixes. * Refactor config_flow to use current pattern. * Add config_flow tests. * Refactor to use signal dispatch helpers. * Remove ToDo: for now. * Remove remaining zone / source logic for initial release, * Stop authenticate blocking, handle timeout. * Removed unneeded code. * Review comments update. * Fix comment. * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Fix bug in seek. * Use sync rather than async update Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Upgrade library, remove exception now caught in library, * Review comments. * Review changes Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Check for duplicate host before adding. * Review comment. Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Remove unused code, revise turn_on/turn_off. * Sync translations. * Make interim timeout const. * Refactor tests. * Add tests with an existing config entry. * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Remove CannotConnect Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
f286992b10
commit
e9b50706a9
@ -718,6 +718,10 @@ omit =
|
||||
homeassistant/components/roomba/roomba.py
|
||||
homeassistant/components/roomba/sensor.py
|
||||
homeassistant/components/roomba/vacuum.py
|
||||
homeassistant/components/roon/__init__.py
|
||||
homeassistant/components/roon/const.py
|
||||
homeassistant/components/roon/media_player.py
|
||||
homeassistant/components/roon/server.py
|
||||
homeassistant/components/route53/*
|
||||
homeassistant/components/rova/sensor.py
|
||||
homeassistant/components/rpi_camera/*
|
||||
|
@ -348,6 +348,7 @@ homeassistant/components/ring/* @balloob
|
||||
homeassistant/components/rmvtransport/* @cgtobi
|
||||
homeassistant/components/roku/* @ctalkington
|
||||
homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn
|
||||
homeassistant/components/roon/* @pavoni
|
||||
homeassistant/components/safe_mode/* @home-assistant/core
|
||||
homeassistant/components/saj/* @fredericvl
|
||||
homeassistant/components/salt/* @bjornorri
|
||||
|
41
homeassistant/components/roon/__init__.py
Normal file
41
homeassistant/components/roon/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Roon (www.roonlabs.com) component."""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .server import RoonServer
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Roon platform."""
|
||||
hass.data[DOMAIN] = {}
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a roonserver from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
roonserver = RoonServer(hass, entry)
|
||||
|
||||
if not await roonserver.async_setup():
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = roonserver
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Roonlabs",
|
||||
name=host,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload a config entry."""
|
||||
roonserver = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return await roonserver.async_reset()
|
108
homeassistant/components/roon/config_flow.py
Normal file
108
homeassistant/components/roon/config_flow.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Config flow for roon integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from roon import RoonApi
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
|
||||
from .const import ( # pylint: disable=unused-import
|
||||
AUTHENTICATE_TIMEOUT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
ROON_APPINFO,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_SCHEMA = vol.Schema({"host": str})
|
||||
|
||||
TIMEOUT = 120
|
||||
|
||||
|
||||
class RoonHub:
|
||||
"""Interact with roon during config flow."""
|
||||
|
||||
def __init__(self, host):
|
||||
"""Initialize."""
|
||||
self._host = host
|
||||
|
||||
async def authenticate(self, hass) -> bool:
|
||||
"""Test if we can authenticate with the host."""
|
||||
token = None
|
||||
secs = 0
|
||||
roonapi = RoonApi(ROON_APPINFO, None, self._host, blocking_init=False)
|
||||
while secs < TIMEOUT:
|
||||
token = roonapi.token
|
||||
secs += AUTHENTICATE_TIMEOUT
|
||||
if token:
|
||||
break
|
||||
await asyncio.sleep(AUTHENTICATE_TIMEOUT)
|
||||
|
||||
token = roonapi.token
|
||||
roonapi.stop()
|
||||
return token
|
||||
|
||||
|
||||
async def authenticate(hass: core.HomeAssistant, host):
|
||||
"""Connect and authenticate home assistant."""
|
||||
|
||||
hub = RoonHub(host)
|
||||
token = await hub.authenticate(hass)
|
||||
if token is None:
|
||||
raise InvalidAuth
|
||||
|
||||
return {CONF_HOST: host, CONF_API_KEY: token}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for roon."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Roon flow."""
|
||||
self._host = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle getting host details from the user."""
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._host = user_input["host"]
|
||||
existing = {
|
||||
entry.data[CONF_HOST] for entry in self._async_current_entries()
|
||||
}
|
||||
if self._host in existing:
|
||||
errors["base"] = "duplicate_entry"
|
||||
return self.async_show_form(step_id="user", errors=errors)
|
||||
|
||||
return await self.async_step_link()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_link(self, user_input=None):
|
||||
"""Handle linking and authenticting with the roon server."""
|
||||
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await authenticate(self.hass, self._host)
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title=DEFAULT_NAME, data=info)
|
||||
|
||||
return self.async_show_form(step_id="link", errors=errors)
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
18
homeassistant/components/roon/const.py
Normal file
18
homeassistant/components/roon/const.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Constants for Roon Component."""
|
||||
|
||||
AUTHENTICATE_TIMEOUT = 5
|
||||
|
||||
DOMAIN = "roon"
|
||||
|
||||
DATA_CONFIGS = "roon_configs"
|
||||
|
||||
DEFAULT_NAME = "Roon Labs Music Player"
|
||||
|
||||
ROON_APPINFO = {
|
||||
"extension_id": "home_assistant",
|
||||
"display_name": "Roon Integration for Home Assistant",
|
||||
"display_version": "1.0.0",
|
||||
"publisher": "home_assistant",
|
||||
"email": "home_assistant@users.noreply.github.com",
|
||||
"website": "https://www.home-assistant.io/",
|
||||
}
|
12
homeassistant/components/roon/manifest.json
Normal file
12
homeassistant/components/roon/manifest.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "roon",
|
||||
"name": "RoonLabs music player",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/roon",
|
||||
"requirements": [
|
||||
"roonapi==0.0.21"
|
||||
],
|
||||
"codeowners": [
|
||||
"@pavoni"
|
||||
]
|
||||
}
|
431
homeassistant/components/roon/media_player.py
Normal file
431
homeassistant/components/roon/media_player.py
Normal file
@ -0,0 +1,431 @@
|
||||
"""MediaPlayer platform for Roon integration."""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.media_player import MediaPlayerEntity
|
||||
from homeassistant.components.media_player.const import (
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
SUPPORT_PLAY_MEDIA,
|
||||
SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_SEEK,
|
||||
SUPPORT_SHUFFLE_SET,
|
||||
SUPPORT_STOP,
|
||||
SUPPORT_TURN_OFF,
|
||||
SUPPORT_TURN_ON,
|
||||
SUPPORT_VOLUME_MUTE,
|
||||
SUPPORT_VOLUME_SET,
|
||||
SUPPORT_VOLUME_STEP,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEVICE_DEFAULT_NAME,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.util import convert
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SUPPORT_ROON = (
|
||||
SUPPORT_PAUSE
|
||||
| SUPPORT_VOLUME_SET
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
| SUPPORT_NEXT_TRACK
|
||||
| SUPPORT_SHUFFLE_SET
|
||||
| SUPPORT_SEEK
|
||||
| SUPPORT_TURN_ON
|
||||
| SUPPORT_TURN_OFF
|
||||
| SUPPORT_VOLUME_MUTE
|
||||
| SUPPORT_PLAY
|
||||
| SUPPORT_PLAY_MEDIA
|
||||
| SUPPORT_VOLUME_STEP
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up Roon MediaPlayer from Config Entry."""
|
||||
roon_server = hass.data[DOMAIN][config_entry.entry_id]
|
||||
media_players = set()
|
||||
|
||||
@callback
|
||||
def async_update_media_player(player_data):
|
||||
"""Add or update Roon MediaPlayer."""
|
||||
dev_id = player_data["dev_id"]
|
||||
if dev_id not in media_players:
|
||||
# new player!
|
||||
media_player = RoonDevice(roon_server, player_data)
|
||||
media_players.add(dev_id)
|
||||
async_add_entities([media_player])
|
||||
else:
|
||||
# update existing player
|
||||
async_dispatcher_send(
|
||||
hass, f"room_media_player_update_{dev_id}", player_data
|
||||
)
|
||||
|
||||
# start listening for players to be added or changed by the server component
|
||||
async_dispatcher_connect(hass, "roon_media_player", async_update_media_player)
|
||||
|
||||
|
||||
class RoonDevice(MediaPlayerEntity):
|
||||
"""Representation of an Roon device."""
|
||||
|
||||
def __init__(self, server, player_data):
|
||||
"""Initialize Roon device object."""
|
||||
self._remove_signal_status = None
|
||||
self._server = server
|
||||
self._available = True
|
||||
self._last_position_update = None
|
||||
self._supports_standby = False
|
||||
self._state = STATE_IDLE
|
||||
self._last_playlist = None
|
||||
self._last_media = None
|
||||
self._unique_id = None
|
||||
self._zone_id = None
|
||||
self._output_id = None
|
||||
self._name = DEVICE_DEFAULT_NAME
|
||||
self._media_title = None
|
||||
self._media_album_name = None
|
||||
self._media_artist = None
|
||||
self._media_position = 0
|
||||
self._media_duration = 0
|
||||
self._is_volume_muted = False
|
||||
self._volume_step = 0
|
||||
self._shuffle = False
|
||||
self._media_image_url = None
|
||||
self._volume_level = 0
|
||||
self.update_data(player_data)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callback."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"room_media_player_update_{self.unique_id}",
|
||||
self.async_update_callback,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_update_callback(self, player_data):
|
||||
"""Handle device updates."""
|
||||
self.update_data(player_data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
return SUPPORT_ROON
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return the device info."""
|
||||
dev_model = "player"
|
||||
if self.player_data.get("source_controls"):
|
||||
dev_model = self.player_data["source_controls"][0].get("display_name")
|
||||
return {
|
||||
"identifiers": {(DOMAIN, self.unique_id)},
|
||||
"name": self.name,
|
||||
"manufacturer": "RoonLabs",
|
||||
"model": dev_model,
|
||||
"via_hub": (DOMAIN, self._server.host),
|
||||
}
|
||||
|
||||
def update_data(self, player_data=None):
|
||||
"""Update session object."""
|
||||
if player_data:
|
||||
self.player_data = player_data
|
||||
if not self.player_data["is_available"]:
|
||||
# this player was removed
|
||||
self._available = False
|
||||
self._state = STATE_OFF
|
||||
else:
|
||||
self._available = True
|
||||
# determine player state
|
||||
self.update_state()
|
||||
if self.state == STATE_PLAYING:
|
||||
self._last_position_update = utcnow()
|
||||
|
||||
def update_state(self):
|
||||
"""Update the power state and player state."""
|
||||
new_state = ""
|
||||
# power state from source control (if supported)
|
||||
if "source_controls" in self.player_data:
|
||||
for source in self.player_data["source_controls"]:
|
||||
if source["supports_standby"] and source["status"] != "indeterminate":
|
||||
self._supports_standby = True
|
||||
if source["status"] in ["standby", "deselected"]:
|
||||
new_state = STATE_OFF
|
||||
break
|
||||
# determine player state
|
||||
if not new_state:
|
||||
if self.player_data["state"] == "playing":
|
||||
new_state = STATE_PLAYING
|
||||
elif self.player_data["state"] == "loading":
|
||||
new_state = STATE_PLAYING
|
||||
elif self.player_data["state"] == "stopped":
|
||||
new_state = STATE_IDLE
|
||||
elif self.player_data["state"] == "paused":
|
||||
new_state = STATE_PAUSED
|
||||
else:
|
||||
new_state = STATE_IDLE
|
||||
self._state = new_state
|
||||
self._unique_id = self.player_data["dev_id"]
|
||||
self._zone_id = self.player_data["zone_id"]
|
||||
self._output_id = self.player_data["output_id"]
|
||||
self._name = self.player_data["display_name"]
|
||||
self._is_volume_muted = self.player_data["volume"]["is_muted"]
|
||||
self._volume_step = convert(self.player_data["volume"]["step"], int, 0)
|
||||
self._shuffle = self.player_data["settings"]["shuffle"]
|
||||
|
||||
if self.player_data["volume"]["type"] == "db":
|
||||
volume = (
|
||||
convert(self.player_data["volume"]["value"], float, 0.0) / 80 * 100
|
||||
+ 100
|
||||
)
|
||||
else:
|
||||
volume = convert(self.player_data["volume"]["value"], float, 0.0)
|
||||
self._volume_level = convert(volume, int, 0) / 100
|
||||
|
||||
try:
|
||||
self._media_title = self.player_data["now_playing"]["three_line"]["line1"]
|
||||
self._media_artist = self.player_data["now_playing"]["three_line"]["line2"]
|
||||
self._media_album_name = self.player_data["now_playing"]["three_line"][
|
||||
"line3"
|
||||
]
|
||||
self._media_position = convert(
|
||||
self.player_data["now_playing"]["seek_position"], int, 0
|
||||
)
|
||||
self._media_duration = convert(
|
||||
self.player_data["now_playing"]["length"], int, 0
|
||||
)
|
||||
try:
|
||||
image_id = self.player_data["now_playing"]["image_key"]
|
||||
self._media_image_url = self._server.roonapi.get_image(image_id)
|
||||
except KeyError:
|
||||
self._media_image_url = None
|
||||
except KeyError:
|
||||
self._media_title = None
|
||||
self._media_album_name = None
|
||||
self._media_artist = None
|
||||
self._media_position = 0
|
||||
self._media_duration = 0
|
||||
self._media_image_url = None
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self):
|
||||
"""When was the position of the current playing media valid."""
|
||||
# Returns value from homeassistant.util.dt.utcnow().
|
||||
return self._last_position_update
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the id of this roon client."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if entity has to be polled for state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def zone_id(self):
|
||||
"""Return current session Id."""
|
||||
return self._zone_id
|
||||
|
||||
@property
|
||||
def output_id(self):
|
||||
"""Return current session Id."""
|
||||
return self._output_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return device name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Return title currently playing."""
|
||||
return self._media_title
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
"""Album name of current playing media (Music track only)."""
|
||||
return self._media_album_name
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Artist of current playing media (Music track only)."""
|
||||
return self._media_artist
|
||||
|
||||
@property
|
||||
def media_album_artist(self):
|
||||
"""Album artist of current playing media (Music track only)."""
|
||||
return self._media_artist
|
||||
|
||||
@property
|
||||
def media_playlist(self):
|
||||
"""Title of Playlist currently playing."""
|
||||
return self._last_playlist
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
return self._media_image_url
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
"""Return position currently playing."""
|
||||
return self._media_position
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
"""Return total runtime length."""
|
||||
return self._media_duration
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
"""Return current volume level."""
|
||||
return self._volume_level
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Return mute state."""
|
||||
return self._is_volume_muted
|
||||
|
||||
@property
|
||||
def volume_step(self):
|
||||
""".Return volume step size."""
|
||||
return self._volume_step
|
||||
|
||||
@property
|
||||
def supports_standby(self):
|
||||
"""Return power state of source controls."""
|
||||
return self._supports_standby
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return current playstate of the device."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def shuffle(self):
|
||||
"""Boolean if shuffle is enabled."""
|
||||
return self._shuffle
|
||||
|
||||
def media_play(self):
|
||||
"""Send play command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "play")
|
||||
|
||||
def media_pause(self):
|
||||
"""Send pause command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "pause")
|
||||
|
||||
def media_play_pause(self):
|
||||
"""Toggle play command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "playpause")
|
||||
|
||||
def media_stop(self):
|
||||
"""Send stop command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "stop")
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send next track command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "next")
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send previous track command to device."""
|
||||
self._server.roonapi.playback_control(self.output_id, "previous")
|
||||
|
||||
def media_seek(self, position):
|
||||
"""Send seek command to device."""
|
||||
self._server.roonapi.seek(self.output_id, position)
|
||||
# Seek doesn't cause an async update - so force one
|
||||
self._media_position = position
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
"""Send new volume_level to device."""
|
||||
volume = int(volume * 100)
|
||||
self._server.roonapi.change_volume(self.output_id, volume)
|
||||
|
||||
def mute_volume(self, mute=True):
|
||||
"""Send mute/unmute to device."""
|
||||
self._server.roonapi.mute(self.output_id, mute)
|
||||
|
||||
def volume_up(self):
|
||||
"""Send new volume_level to device."""
|
||||
self._server.roonapi.change_volume(self.output_id, 3, "relative")
|
||||
|
||||
def volume_down(self):
|
||||
"""Send new volume_level to device."""
|
||||
self._server.roonapi.change_volume(self.output_id, -3, "relative")
|
||||
|
||||
def turn_on(self):
|
||||
"""Turn on device (if supported)."""
|
||||
if not (self.supports_standby and "source_controls" in self.player_data):
|
||||
self.media_play()
|
||||
return
|
||||
for source in self.player_data["source_controls"]:
|
||||
if source["supports_standby"] and source["status"] != "indeterminate":
|
||||
self._server.roonapi.convenience_switch(
|
||||
self.output_id, source["control_key"]
|
||||
)
|
||||
return
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off device (if supported)."""
|
||||
if not (self.supports_standby and "source_controls" in self.player_data):
|
||||
self.media_stop()
|
||||
return
|
||||
|
||||
for source in self.player_data["source_controls"]:
|
||||
if source["supports_standby"] and not source["status"] == "indeterminate":
|
||||
self._server.roonapi.standby(self.output_id, source["control_key"])
|
||||
return
|
||||
|
||||
def set_shuffle(self, shuffle):
|
||||
"""Set shuffle state."""
|
||||
self._server.roonapi.shuffle(self.output_id, shuffle)
|
||||
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Send the play_media command to the media player."""
|
||||
# Roon itself doesn't support playback of media by filename/url so this a bit of a workaround.
|
||||
media_type = media_type.lower()
|
||||
if media_type == "radio":
|
||||
if self._server.roonapi.play_radio(self.zone_id, media_id):
|
||||
self._last_playlist = media_id
|
||||
self._last_media = media_id
|
||||
elif media_type == "playlist":
|
||||
if self._server.roonapi.play_playlist(
|
||||
self.zone_id, media_id, shuffle=False
|
||||
):
|
||||
self._last_playlist = media_id
|
||||
elif media_type == "shuffleplaylist":
|
||||
if self._server.roonapi.play_playlist(self.zone_id, media_id, shuffle=True):
|
||||
self._last_playlist = media_id
|
||||
elif media_type == "queueplaylist":
|
||||
self._server.roonapi.queue_playlist(self.zone_id, media_id)
|
||||
elif media_type == "genre":
|
||||
self._server.roonapi.play_genre(self.zone_id, media_id)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Playback requested of unsupported type: %s --> %s",
|
||||
media_type,
|
||||
media_id,
|
||||
)
|
153
homeassistant/components/roon/server.py
Normal file
153
homeassistant/components/roon/server.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Code to handle the api connection to a Roon server."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from roon import RoonApi
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import ROON_APPINFO
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
FULL_SYNC_INTERVAL = 30
|
||||
|
||||
|
||||
class RoonServer:
|
||||
"""Manages a single Roon Server."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the system."""
|
||||
self.config_entry = config_entry
|
||||
self.hass = hass
|
||||
self.roonapi = None
|
||||
self.all_player_ids = set()
|
||||
self.all_playlists = []
|
||||
self.offline_devices = set()
|
||||
self._exit = False
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""Return the host of this server."""
|
||||
return self.config_entry.data[CONF_HOST]
|
||||
|
||||
async def async_setup(self, tries=0):
|
||||
"""Set up a roon server based on host parameter."""
|
||||
host = self.host
|
||||
hass = self.hass
|
||||
token = self.config_entry.data[CONF_API_KEY]
|
||||
_LOGGER.debug("async_setup: %s %s", token, host)
|
||||
self.roonapi = RoonApi(ROON_APPINFO, token, host, blocking_init=False)
|
||||
self.roonapi.register_state_callback(
|
||||
self.roonapi_state_callback, event_filter=["zones_changed"]
|
||||
)
|
||||
|
||||
# initialize media_player platform
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(
|
||||
self.config_entry, "media_player"
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize Roon background polling
|
||||
asyncio.create_task(self.async_do_loop())
|
||||
|
||||
return True
|
||||
|
||||
async def async_reset(self):
|
||||
"""Reset this connection to default state.
|
||||
|
||||
Will cancel any scheduled setup retry and will unload
|
||||
the config entry.
|
||||
"""
|
||||
self.stop_roon()
|
||||
return True
|
||||
|
||||
@property
|
||||
def zones(self):
|
||||
"""Return list of zones."""
|
||||
return self.roonapi.zones
|
||||
|
||||
def stop_roon(self):
|
||||
"""Stop background worker."""
|
||||
self.roonapi.stop()
|
||||
self._exit = True
|
||||
|
||||
def roonapi_state_callback(self, event, changed_zones):
|
||||
"""Callbacks from the roon api websockets."""
|
||||
self.hass.add_job(self.async_update_changed_players(changed_zones))
|
||||
|
||||
async def async_do_loop(self):
|
||||
"""Background work loop."""
|
||||
self._exit = False
|
||||
while not self._exit:
|
||||
await self.async_update_players()
|
||||
# await self.async_update_playlists()
|
||||
await asyncio.sleep(FULL_SYNC_INTERVAL)
|
||||
|
||||
async def async_update_changed_players(self, changed_zones_ids):
|
||||
"""Update the players which were reported as changed by the Roon API."""
|
||||
for zone_id in changed_zones_ids:
|
||||
if zone_id not in self.roonapi.zones:
|
||||
# device was removed ?
|
||||
continue
|
||||
zone = self.roonapi.zones[zone_id]
|
||||
for device in zone["outputs"]:
|
||||
dev_name = device["display_name"]
|
||||
if dev_name == "Unnamed" or not dev_name:
|
||||
# ignore unnamed devices
|
||||
continue
|
||||
player_data = await self.async_create_player_data(zone, device)
|
||||
dev_id = player_data["dev_id"]
|
||||
player_data["is_available"] = True
|
||||
if dev_id in self.offline_devices:
|
||||
# player back online
|
||||
self.offline_devices.remove(dev_id)
|
||||
async_dispatcher_send(self.hass, "roon_media_player", player_data)
|
||||
self.all_player_ids.add(dev_id)
|
||||
|
||||
async def async_update_players(self):
|
||||
"""Periodic full scan of all devices."""
|
||||
zone_ids = self.roonapi.zones.keys()
|
||||
await self.async_update_changed_players(zone_ids)
|
||||
# check for any removed devices
|
||||
all_devs = {}
|
||||
for zone in self.roonapi.zones.values():
|
||||
for device in zone["outputs"]:
|
||||
player_data = await self.async_create_player_data(zone, device)
|
||||
dev_id = player_data["dev_id"]
|
||||
all_devs[dev_id] = player_data
|
||||
for dev_id in self.all_player_ids:
|
||||
if dev_id in all_devs:
|
||||
continue
|
||||
# player was removed!
|
||||
player_data = {"dev_id": dev_id}
|
||||
player_data["is_available"] = False
|
||||
async_dispatcher_send(self.hass, "roon_media_player", player_data)
|
||||
self.offline_devices.add(dev_id)
|
||||
|
||||
async def async_update_playlists(self):
|
||||
"""Store lists in memory with all playlists - could be used by a custom lovelace card."""
|
||||
all_playlists = []
|
||||
roon_playlists = self.roonapi.playlists()
|
||||
if roon_playlists and "items" in roon_playlists:
|
||||
all_playlists += [item["title"] for item in roon_playlists["items"]]
|
||||
roon_playlists = self.roonapi.internet_radio()
|
||||
if roon_playlists and "items" in roon_playlists:
|
||||
all_playlists += [item["title"] for item in roon_playlists["items"]]
|
||||
self.all_playlists = all_playlists
|
||||
|
||||
async def async_create_player_data(self, zone, output):
|
||||
"""Create player object dict by combining zone with output."""
|
||||
new_dict = zone.copy()
|
||||
new_dict.update(output)
|
||||
new_dict.pop("outputs")
|
||||
new_dict["host"] = self.host
|
||||
new_dict["is_synced"] = len(zone["outputs"]) > 1
|
||||
new_dict["zone_name"] = zone["display_name"]
|
||||
new_dict["display_name"] = output["display_name"]
|
||||
new_dict["last_changed"] = utcnow()
|
||||
# we don't use the zone_id or output_id for now as unique id as I've seen cases were it changes for some reason
|
||||
new_dict["dev_id"] = f"roon_{self.host}_{output['display_name']}"
|
||||
return new_dict
|
26
homeassistant/components/roon/strings.json
Normal file
26
homeassistant/components/roon/strings.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "Roon",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configure Roon Server",
|
||||
"description": "Please enter your Roon server Hostname or IP.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
},
|
||||
"link": {
|
||||
"title": "Authorize HomeAssistant in Roon",
|
||||
"description": "You must authorize Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"duplicate_entry": "That host has already been added."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
26
homeassistant/components/roon/translations/en.json
Normal file
26
homeassistant/components/roon/translations/en.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"duplicate_entry": "That host has already been added.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"link": {
|
||||
"description": "You must authorize Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab.",
|
||||
"title": "Authorize HomeAssistant in Roon"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"description": "Please enter your Roon server Hostname or IP.",
|
||||
"title": "Configure Roon Server"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Roon"
|
||||
}
|
@ -144,6 +144,7 @@ FLOWS = [
|
||||
"ring",
|
||||
"roku",
|
||||
"roomba",
|
||||
"roon",
|
||||
"samsungtv",
|
||||
"sense",
|
||||
"sentry",
|
||||
|
@ -1914,6 +1914,9 @@ rokuecp==0.5.0
|
||||
# homeassistant.components.roomba
|
||||
roombapy==1.6.1
|
||||
|
||||
# homeassistant.components.roon
|
||||
roonapi==0.0.21
|
||||
|
||||
# homeassistant.components.rova
|
||||
rova==0.1.0
|
||||
|
||||
|
@ -871,6 +871,9 @@ rokuecp==0.5.0
|
||||
# homeassistant.components.roomba
|
||||
roombapy==1.6.1
|
||||
|
||||
# homeassistant.components.roon
|
||||
roonapi==0.0.21
|
||||
|
||||
# homeassistant.components.yamaha
|
||||
rxv==0.6.0
|
||||
|
||||
|
1
tests/components/roon/__init__.py
Normal file
1
tests/components/roon/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the roon integration."""
|
159
tests/components/roon/test_config_flow.py
Normal file
159
tests/components/roon/test_config_flow.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""Test the roon config flow."""
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.roon.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from tests.async_mock import patch
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
class RoonApiMock:
|
||||
"""Mock to handle returning tokens for testing the RoonApi."""
|
||||
|
||||
def __init__(self, token):
|
||||
"""Initialize."""
|
||||
self._token = token
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
"""Return the auth token from the api."""
|
||||
return self._token
|
||||
|
||||
def stop(self): # pylint: disable=no-self-use
|
||||
"""Close down the api."""
|
||||
return
|
||||
|
||||
|
||||
async def test_form_and_auth(hass):
|
||||
"""Test we get the form."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
|
||||
"homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT", 0,
|
||||
), patch(
|
||||
"homeassistant.components.roon.config_flow.RoonApi",
|
||||
return_value=RoonApiMock("good_token"),
|
||||
), patch(
|
||||
"homeassistant.components.roon.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.roon.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "1.1.1.1"}
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "Roon Labs Music Player"
|
||||
assert result2["data"] == {"host": "1.1.1.1", "api_key": "good_token"}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_no_token(hass):
|
||||
"""Test we handle no token being returned (timeout or not authorized)."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
|
||||
"homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT", 0,
|
||||
), patch(
|
||||
"homeassistant.components.roon.config_flow.RoonApi",
|
||||
return_value=RoonApiMock(None),
|
||||
):
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "1.1.1.1"}
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_unknown_exception(hass):
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.roon.config_flow.RoonApi", side_effect=Exception,
|
||||
):
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "1.1.1.1"}
|
||||
)
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_form_host_already_exists(hass):
|
||||
"""Test we add the host if the config exists and it isn't a duplicate."""
|
||||
|
||||
MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "existing_host"}).add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
|
||||
"homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT", 0,
|
||||
), patch(
|
||||
"homeassistant.components.roon.config_flow.RoonApi",
|
||||
return_value=RoonApiMock("good_token"),
|
||||
), patch(
|
||||
"homeassistant.components.roon.async_setup", return_value=True
|
||||
) as mock_setup, patch(
|
||||
"homeassistant.components.roon.async_setup_entry", return_value=True,
|
||||
) as mock_setup_entry:
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "1.1.1.1"}
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result2["type"] == "create_entry"
|
||||
assert result2["title"] == "Roon Labs Music Player"
|
||||
assert result2["data"] == {"host": "1.1.1.1", "api_key": "good_token"}
|
||||
await hass.async_block_till_done()
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 2
|
||||
|
||||
|
||||
async def test_form_duplicate_host(hass):
|
||||
"""Test we don't add the host if it's a duplicate."""
|
||||
|
||||
MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "existing_host"}).add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"host": "existing_host"}
|
||||
)
|
||||
|
||||
assert result2["type"] == "form"
|
||||
assert result2["errors"] == {"base": "duplicate_entry"}
|
Loading…
x
Reference in New Issue
Block a user