diff --git a/.coveragerc b/.coveragerc index 6809a58f157..e6fcfea3543 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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/* diff --git a/CODEOWNERS b/CODEOWNERS index a92eac1940d..768c99cb698 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/roon/__init__.py b/homeassistant/components/roon/__init__.py new file mode 100644 index 00000000000..ff45e2c16cf --- /dev/null +++ b/homeassistant/components/roon/__init__.py @@ -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() diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py new file mode 100644 index 00000000000..7fac3186c03 --- /dev/null +++ b/homeassistant/components/roon/config_flow.py @@ -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.""" diff --git a/homeassistant/components/roon/const.py b/homeassistant/components/roon/const.py new file mode 100644 index 00000000000..dc11d4167a7 --- /dev/null +++ b/homeassistant/components/roon/const.py @@ -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/", +} diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json new file mode 100644 index 00000000000..d6bf70e427b --- /dev/null +++ b/homeassistant/components/roon/manifest.json @@ -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" + ] +} diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py new file mode 100644 index 00000000000..c4aa3dc8a2c --- /dev/null +++ b/homeassistant/components/roon/media_player.py @@ -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, + ) diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py new file mode 100644 index 00000000000..df6051c287a --- /dev/null +++ b/homeassistant/components/roon/server.py @@ -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 diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json new file mode 100644 index 00000000000..741fc01605f --- /dev/null +++ b/homeassistant/components/roon/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/roon/translations/en.json b/homeassistant/components/roon/translations/en.json new file mode 100644 index 00000000000..230fe87809a --- /dev/null +++ b/homeassistant/components/roon/translations/en.json @@ -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" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9b88a7bda0f..4a4548571ec 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -144,6 +144,7 @@ FLOWS = [ "ring", "roku", "roomba", + "roon", "samsungtv", "sense", "sentry", diff --git a/requirements_all.txt b/requirements_all.txt index 9f538fb497b..810fd65fab5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e766138092..ee2f251428e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/roon/__init__.py b/tests/components/roon/__init__.py new file mode 100644 index 00000000000..4babd3f177e --- /dev/null +++ b/tests/components/roon/__init__.py @@ -0,0 +1 @@ +"""Tests for the roon integration.""" diff --git a/tests/components/roon/test_config_flow.py b/tests/components/roon/test_config_flow.py new file mode 100644 index 00000000000..8b6df6a35fd --- /dev/null +++ b/tests/components/roon/test_config_flow.py @@ -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"}