diff --git a/.coveragerc b/.coveragerc index eb24287069e..90ce03476a8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -926,6 +926,7 @@ omit = homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py + homeassistant/components/volumio/__init__.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* homeassistant/components/w800rf32/* diff --git a/CODEOWNERS b/CODEOWNERS index f488eace2bd..f5ece6fedf2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -454,6 +454,7 @@ homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf +homeassistant/components/volumio/* @OnFreund homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 65392fa767a..921b76168ca 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -71,7 +71,6 @@ SERVICE_HANDLERS = { "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "kodi": ("media_player", "kodi"), - "volumio": ("media_player", "volumio"), "lg_smart_device": ("media_player", "lg_soundbar"), "nanoleaf_aurora": ("light", "nanoleaf"), } @@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [ "songpal", SERVICE_WEMO, SERVICE_XIAOMI_GW, + "volumio", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/volumio/__init__.py b/homeassistant/components/volumio/__init__.py index 823533336ba..8d171cab9d2 100644 --- a/homeassistant/components/volumio/__init__.py +++ b/homeassistant/components/volumio/__init__.py @@ -1 +1,59 @@ -"""The volumio component.""" +"""The Volumio integration.""" +import asyncio + +from pyvolumio import CannotConnectError, Volumio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + +PLATFORMS = ["media_player"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Volumio component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Volumio from a config entry.""" + + volumio = Volumio( + entry.data[CONF_HOST], entry.data[CONF_PORT], async_get_clientsession(hass) + ) + try: + info = await volumio.get_system_version() + except CannotConnectError as error: + raise ConfigEntryNotReady from error + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_VOLUMIO: volumio, + DATA_INFO: info, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py new file mode 100644 index 00000000000..8b68a4d38de --- /dev/null +++ b/homeassistant/components/volumio/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for Volumio integration.""" +import logging +from typing import Optional + +from pyvolumio import CannotConnectError, Volumio +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=3000): int} +) + + +async def validate_input(hass, host, port): + """Validate the user input allows us to connect.""" + volumio = Volumio(host, port, async_get_clientsession(hass)) + + try: + return await volumio.get_system_info() + except CannotConnectError as error: + raise CannotConnect from error + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Volumio.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize flow.""" + self._host: Optional[str] = None + self._port: Optional[int] = None + self._name: Optional[str] = None + self._uuid: Optional[str] = None + + @callback + def _async_get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + CONF_NAME: self._name, + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_ID: self._uuid, + }, + ) + + async def _set_uid_and_abort(self): + await self.async_set_unique_id(self._uuid) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NAME: self._name, + } + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + info = None + try: + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + info = await validate_input(self.hass, self._host, self._port) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if info is not None: + self._name = info.get("name", self._host) + self._uuid = info.get("id", None) + if self._uuid is not None: + await self._set_uid_and_abort() + + return self._async_get_entry() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + """Handle zeroconf discovery.""" + self._host = discovery_info["host"] + self._port = int(discovery_info["port"]) + self._name = discovery_info["properties"]["volumioName"] + self._uuid = discovery_info["properties"]["UUID"] + + await self._set_uid_and_abort() + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + if user_input is not None: + try: + await validate_input(self.hass, self._host, self._port) + return self._async_get_entry() + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + return self.async_show_form( + step_id="discovery_confirm", description_placeholders={"name": self._name} + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/volumio/const.py b/homeassistant/components/volumio/const.py new file mode 100644 index 00000000000..608c029a85e --- /dev/null +++ b/homeassistant/components/volumio/const.py @@ -0,0 +1,6 @@ +"""Constants for the Volumio integration.""" + +DOMAIN = "volumio" + +DATA_INFO = "info" +DATA_VOLUMIO = "volumio" diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index 7fed8811600..95d84fd7ee6 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -2,5 +2,8 @@ "domain": "volumio", "name": "Volumio", "documentation": "https://www.home-assistant.io/integrations/volumio", - "codeowners": [] -} + "codeowners": ["@OnFreund"], + "config_flow": true, + "zeroconf": ["_Volumio._tcp.local."], + "requirements": ["pyvolumio==0.1"] +} \ No newline at end of file diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 58b3b4a04ba..0fadb5b51ed 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -3,15 +3,10 @@ Volumio Platform. Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ -import asyncio from datetime import timedelta import logging -import socket -import aiohttp -import voluptuous as vol - -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, @@ -28,29 +23,19 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( - CONF_HOST, + CONF_ID, CONF_NAME, - CONF_PORT, - HTTP_OK, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "Volumio" -DEFAULT_PORT = 3000 - -DATA_VOLUMIO = "volumio" - -TIMEOUT = 10 - SUPPORT_VOLUMIO = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET @@ -68,91 +53,59 @@ SUPPORT_VOLUMIO = ( PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Volumio media player platform.""" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Volumio platform.""" - if DATA_VOLUMIO not in hass.data: - hass.data[DATA_VOLUMIO] = {} + data = hass.data[DOMAIN][config_entry.entry_id] + volumio = data[DATA_VOLUMIO] + info = data[DATA_INFO] + uid = config_entry.data[CONF_ID] + name = config_entry.data[CONF_NAME] - # This is a manual configuration? - if discovery_info is None: - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - else: - name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname")) - host = discovery_info.get("host") - port = discovery_info.get("port") - - # Only add a device once, so discovered devices do not override manual - # config. - ip_addr = socket.gethostbyname(host) - if ip_addr in hass.data[DATA_VOLUMIO]: - return - - entity = Volumio(name, host, port, hass) - - hass.data[DATA_VOLUMIO][ip_addr] = entity + entity = Volumio(hass, volumio, uid, name, info) async_add_entities([entity]) class Volumio(MediaPlayerEntity): """Volumio Player Object.""" - def __init__(self, name, host, port, hass): + def __init__(self, hass, volumio, uid, name, info): """Initialize the media player.""" - self.host = host - self.port = port - self.hass = hass - self._url = "{}:{}".format(host, str(port)) + self._hass = hass + self._volumio = volumio + self._uid = uid self._name = name + self._info = info self._state = {} - self._lastvol = self._state.get("volume", 0) self._playlists = [] self._currentplaylist = None - async def send_volumio_msg(self, method, params=None): - """Send message.""" - url = f"http://{self.host}:{self.port}/api/v1/{method}/" - - _LOGGER.debug("URL: %s params: %s", url, params) - - try: - websession = async_get_clientsession(self.hass) - response = await websession.get(url, params=params) - if response.status == HTTP_OK: - data = await response.json() - else: - _LOGGER.error( - "Query failed, response code: %s Full message: %s", - response.status, - response, - ) - return False - - except (asyncio.TimeoutError, aiohttp.ClientError) as error: - _LOGGER.error( - "Failed communicating with Volumio '%s': %s", self._name, type(error) - ) - return False - - return data - async def async_update(self): """Update state.""" - resp = await self.send_volumio_msg("getState") + self._state = await self._volumio.get_state() await self._async_update_playlists() - if resp is False: - return - self._state = resp.copy() + + @property + def unique_id(self): + """Return the unique id for the entity.""" + return self._uid + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Volumio", + "sw_version": self._info["systemversion"], + "model": self._info["hardware"], + } @property def media_content_type(self): @@ -189,13 +142,7 @@ class Volumio(MediaPlayerEntity): def media_image_url(self): """Image url of current playing media.""" url = self._state.get("albumart", None) - if url is None: - return - if str(url[0:2]).lower() == "ht": - mediaurl = url - else: - mediaurl = f"http://{self.host}:{self.port}{url}" - return mediaurl + return self._volumio.canonic_url(url) @property def media_seek_position(self): @@ -220,11 +167,6 @@ class Volumio(MediaPlayerEntity): """Boolean if volume is currently muted.""" return self._state.get("mute", None) - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def shuffle(self): """Boolean if shuffle is enabled.""" @@ -247,79 +189,61 @@ class Volumio(MediaPlayerEntity): async def async_media_next_track(self): """Send media_next command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "next"}) + await self._volumio.next() async def async_media_previous_track(self): """Send media_previous command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "prev"}) + await self._volumio.previous() async def async_media_play(self): """Send media_play command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "play"}) + await self._volumio.play() async def async_media_pause(self): """Send media_pause command to media player.""" if self._state["trackType"] == "webradio": - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() else: - await self.send_volumio_msg("commands", params={"cmd": "pause"}) + await self._volumio.pause() async def async_media_stop(self): """Send media_stop command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": int(volume * 100)} - ) + await self._volumio.set_volume_level(int(volume * 100)) async def async_volume_up(self): """Service to send the Volumio the command for volume up.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "plus"} - ) + await self._volumio.volume_up() async def async_volume_down(self): """Service to send the Volumio the command for volume down.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "minus"} - ) + await self._volumio.volume_down() async def async_mute_volume(self, mute): """Send mute command to media player.""" - mutecmd = "mute" if mute else "unmute" if mute: - # mute is implemented as 0 volume, do save last volume level - self._lastvol = self._state["volume"] - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": mutecmd} - ) - return - - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": self._lastvol} - ) + await self._volumio.mute() + else: + await self._volumio.unmute() async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - await self.send_volumio_msg( - "commands", params={"cmd": "random", "value": str(shuffle).lower()} - ) + await self._volumio.set_shuffle(shuffle) async def async_select_source(self, source): - """Choose a different available playlist and play it.""" + """Choose an available playlist and play it.""" + await self._volumio.play_playlist(source) self._currentplaylist = source - await self.send_volumio_msg( - "commands", params={"cmd": "playplaylist", "name": source} - ) async def async_clear_playlist(self): """Clear players playlist.""" + await self._volumio.clear_playlist() self._currentplaylist = None - await self.send_volumio_msg("commands", params={"cmd": "clearQueue"}) @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self.send_volumio_msg("listplaylists") + self._playlists = await self._volumio.get_playlists() diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json new file mode 100644 index 00000000000..ffa53b2c438 --- /dev/null +++ b/homeassistant/components/volumio/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "discovery_confirm": { + "description": "Do you want to add Volumio (`{name}`) to Home Assistant?", + "title": "Discovered Volumio" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to discovered Volumio" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 736f16e3581..df56e071923 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -184,6 +184,7 @@ FLOWS = [ "vesync", "vilfo", "vizio", + "volumio", "wemo", "wiffi", "withings", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index dc2bd289930..872b07f5c6a 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest # fmt: off ZEROCONF = { + "_Volumio._tcp.local.": [ + "volumio" + ], "_api._udp.local.": [ "guardian" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5246467e758..96adaf77fad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1827,6 +1827,9 @@ pyvizio==0.1.49 # homeassistant.components.velux pyvlx==0.2.16 +# homeassistant.components.volumio +pyvolumio==0.1 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5730fbff9c4..1ce16d7e51f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -826,6 +826,9 @@ pyvesync==1.1.0 # homeassistant.components.vizio pyvizio==0.1.49 +# homeassistant.components.volumio +pyvolumio==0.1 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/volumio/__init__.py b/tests/components/volumio/__init__.py new file mode 100644 index 00000000000..7d8a443aaf8 --- /dev/null +++ b/tests/components/volumio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volumio integration.""" diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py new file mode 100644 index 00000000000..a7ed4773142 --- /dev/null +++ b/tests/components/volumio/test_config_flow.py @@ -0,0 +1,252 @@ +"""Test the Volumio config flow.""" +from homeassistant import config_entries +from homeassistant.components.volumio.config_flow import CannotConnectError +from homeassistant.components.volumio.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +TEST_SYSTEM_INFO = {"id": "1111-1111-1111-1111", "name": "TestVolumio"} + + +TEST_CONNECTION = { + "host": "1.1.1.1", + "port": 3000, +} + + +TEST_DISCOVERY = { + "host": "1.1.1.1", + "port": 3000, + "properties": {"volumioName": "discovered", "UUID": "2222-2222-2222-2222"}, +} + +TEST_DISCOVERY_RESULT = { + "host": TEST_DISCOVERY["host"], + "port": TEST_DISCOVERY["port"], + "id": TEST_DISCOVERY["properties"]["UUID"], + "name": TEST_DISCOVERY["properties"]["volumioName"], +} + + +async def test_form(hass): + """Test we get the form.""" + 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.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "TestVolumio" + assert result2["data"] == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + 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_updates_unique_id(hass): + """Test a duplicate id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SYSTEM_INFO["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_SYSTEM_INFO["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch("homeassistant.components.volumio.async_setup", return_value=True), patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + assert entry.data == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + +async def test_empty_system_info(hass): + """Test old volumio versions with empty system info.""" + 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.volumio.config_flow.Volumio.get_system_info", + return_value={}, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_CONNECTION["host"] + assert result2["data"] == { + "host": TEST_CONNECTION["host"], + "port": TEST_CONNECTION["port"], + "name": TEST_CONNECTION["host"], + "id": None, + } + + 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_cannot_connect(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.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_exception(hass): + """Test we handle generic error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_discovery(hass): + """Test discovery flow works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_DISCOVERY_RESULT["name"] + assert result2["data"] == TEST_DISCOVERY_RESULT + + assert result2["result"] + assert result2["result"].unique_id == TEST_DISCOVERY_RESULT["id"] + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_cannot_connect(hass): + """Test discovery aborts if cannot connect.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "cannot_connect" + + +async def test_discovery_duplicate_data(hass): + """Test discovery aborts if same mDNS packet arrives.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "abort" + assert result["reason"] == "already_in_progress" + + +async def test_discovery_updates_unique_id(hass): + """Test a duplicate discovery id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_DISCOVERY_RESULT["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_DISCOVERY_RESULT["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + assert entry.data == TEST_DISCOVERY_RESULT