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:
Greg Dowling 2020-08-12 14:09:47 +01:00 committed by GitHub
parent f286992b10
commit e9b50706a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 987 additions and 0 deletions

View File

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

View File

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

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

View 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."""

View 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/",
}

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

View 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,
)

View 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

View 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%]"
}
}
}

View 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"
}

View File

@ -144,6 +144,7 @@ FLOWS = [
"ring",
"roku",
"roomba",
"roon",
"samsungtv",
"sense",
"sentry",

View File

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

View File

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

View File

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

View 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"}