From 1f4ec26eeeb165abd9aa8869a346b764d01d1b07 Mon Sep 17 00:00:00 2001 From: Barrett Lowe Date: Sat, 1 May 2021 15:06:35 +0000 Subject: [PATCH 1/2] initial stab at snapcast config flow --- .coveragerc | 1 - homeassistant/components/snapcast/__init__.py | 39 +++++++++- .../components/snapcast/config_flow.py | 72 +++++++++++++++++++ homeassistant/components/snapcast/const.py | 4 ++ .../components/snapcast/manifest.json | 17 +++-- .../components/snapcast/media_player.py | 65 ++++++++++++++--- .../components/snapcast/strings.json | 18 +++++ .../components/snapcast/translations/en.json | 18 +++++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/snapcast/__init__.py | 10 +++ tests/components/snapcast/test_init.py | 61 ++++++++++++++++ 12 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/snapcast/config_flow.py create mode 100644 homeassistant/components/snapcast/strings.json create mode 100644 homeassistant/components/snapcast/translations/en.json create mode 100644 tests/components/snapcast/__init__.py create mode 100644 tests/components/snapcast/test_init.py diff --git a/.coveragerc b/.coveragerc index 9c030123f720e2..723d0744b1da19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -921,7 +921,6 @@ omit = homeassistant/components/smarthab/light.py homeassistant/components/sms/* homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/* homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/solaredge/__init__.py diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b5279fa3ce06c2..069e50a6ead944 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -1 +1,38 @@ -"""The snapcast component.""" +"""Snapcast Integration.""" +import logging +import socket + +import snapcast.control + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Snapcast component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Snapcast from a config entry.""" + + host = entry.data["host"] + port = entry.data["port"] + try: + hass.data[DOMAIN][entry.entry_id] = await snapcast.control.create_server( + hass.loop, host, port, reconnect=True + ) + except socket.gaierror: + _LOGGER.error("Could not connect to Snapcast server at %s:%d", host, port) + return False + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + return True diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py new file mode 100644 index 00000000000000..809c0e304afa5a --- /dev/null +++ b/homeassistant/components/snapcast/config_flow.py @@ -0,0 +1,72 @@ +"""Snapcast config flow.""" + +from __future__ import annotations + +import logging +import socket + +import snapcast.control +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SNAPCAST_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + + +class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): + """Snapcast config flow.""" + + async def async_step_user(self, user_input=None): + """Handle first step.""" + + def _show_form(errors={}): + return self.async_show_form( + step_id="user", + data_schema=SNAPCAST_SCHEMA, + errors=errors, + ) + + if not user_input: + return _show_form() + + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + errors = {} + + # Attempt to create the server - make sure it's going to work + try: + client = await snapcast.control.create_server( + self.hass.loop, host, port, reconnect=True + ) + except socket.gaierror: + errors["base"] = "unknown" + except ConnectionRefusedError: + errors["base"] = "cannot_connect" + finally: + if "client" in locals(): + del client + + await self.async_set_unique_id("Snapcast") + self._abort_if_unique_id_configured() + + if errors: + _LOGGER.error( + "Could not connect to a Snapcast Server at the provided address" + ) + _LOGGER.error(f"Error: {errors['base']}") + return _show_form(errors=errors) + + return self.async_create_entry( + title=f"{host}:{port}", + data=user_input, + ) diff --git a/homeassistant/components/snapcast/const.py b/homeassistant/components/snapcast/const.py index 674a22993b910c..0a0edcce2dd044 100644 --- a/homeassistant/components/snapcast/const.py +++ b/homeassistant/components/snapcast/const.py @@ -15,3 +15,7 @@ ATTR_MASTER = "master" ATTR_LATENCY = "latency" + +DEFAULT_PORT = 1705 + +DOMAIN = "snapcast" diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 2e3249f4551d74..9be1e121d287f4 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -1,8 +1,11 @@ { - "domain": "snapcast", - "name": "Snapcast", - "documentation": "https://www.home-assistant.io/integrations/snapcast", - "requirements": ["snapcast==2.1.3"], - "codeowners": [], - "iot_class": "local_polling" -} + "domain": "snapcast", + "name": "Snapcast", + "documentation": "https://www.home-assistant.io/integrations/snapcast", + "requirements": [ + "snapcast==2.1.3" + ], + "codeowners": [], + "iot_class": "local_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index e1c5b7d875b61a..82ef8d509b1151 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -1,26 +1,29 @@ """Support for interacting with Snapcast clients.""" import logging import socket +from typing import Callable import snapcast.control -from snapcast.control.server import CONTROL_PORT +from snapcast.control.server import CONTROL_PORT, Snapserver import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( + SUPPORT_GROUPING, SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, STATE_IDLE, STATE_OFF, - STATE_ON, STATE_PLAYING, STATE_UNKNOWN, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( @@ -29,6 +32,7 @@ CLIENT_PREFIX, CLIENT_SUFFIX, DATA_KEY, + DOMAIN, GROUP_PREFIX, GROUP_SUFFIX, SERVICE_JOIN, @@ -44,7 +48,7 @@ SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_SELECT_SOURCE ) SUPPORT_SNAPCAST_GROUP = ( - SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_SELECT_SOURCE + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_SELECT_SOURCE | SUPPORT_GROUPING ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -52,12 +56,8 @@ ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Snapcast platform.""" - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT, CONTROL_PORT) - +def register_services(): + """Register snapcast services.""" platform = entity_platform.current_platform.get() platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot") platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore") @@ -71,6 +71,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= handle_set_latency, ) + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> bool: + """Set up the snapcast config entry.""" + theServer: Snapserver = hass.data[DOMAIN][config_entry.entry_id] + _LOGGER.debug(theServer) + + register_services() + + host = config_entry.data["host"] + port = config_entry.data["port"] + hpid = f"{host}:{port}" + + groups = [SnapcastGroupDevice(group, hpid) for group in theServer.groups] + clients = [SnapcastClientDevice(client, hpid) for client in theServer.clients] + devices = groups + clients + hass.data[DATA_KEY] = devices + async_add_entities(devices) + + return False + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Snapcast platform.""" + + host = config.get(CONF_HOST) + port = config.get(CONF_PORT, CONTROL_PORT) + + register_services() + try: server = await snapcast.control.create_server( hass.loop, host, port, reconnect=True @@ -174,6 +205,11 @@ def should_poll(self): """Do not poll for state.""" return False + @property + def group_members(self): + """Get the members of the group.""" + return self._group.clients + async def async_select_source(self, source): """Set input source.""" streams = self._group.streams_by_name() @@ -231,7 +267,9 @@ def name(self): @property def source(self): """Return the current input source.""" - return self._client.group.stream + stream = self._client.group.stream + _LOGGER.debug(f"Client source queried - {self._client.identifier}:{stream}") + return stream @property def volume_level(self): @@ -257,7 +295,11 @@ def source_list(self): def state(self): """Return the state of the player.""" if self._client.connected: - return STATE_ON + return { + "idle": STATE_IDLE, + "playing": STATE_PLAYING, + "unknown": STATE_UNKNOWN, + }.get(self._client.group.stream_status, STATE_UNKNOWN) return STATE_OFF @property @@ -311,6 +353,7 @@ async def async_join(self, master): for group in self._client.groups_available() if master_entity.identifier in group.clients ) + _LOGGER.debug(f"master group: {master_group}") await master_group.add_client(self._client.identifier) self.async_write_ha_state() diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json new file mode 100644 index 00000000000000..55c50bb67ee013 --- /dev/null +++ b/homeassistant/components/snapcast/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter your server connection details", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Connect" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snapcast/translations/en.json b/homeassistant/components/snapcast/translations/en.json new file mode 100644 index 00000000000000..f6343f2f749c29 --- /dev/null +++ b/homeassistant/components/snapcast/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "description": "Please enter your server connection details", + "title": "Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3b408860d59a2c..054065cd2bd80d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -222,6 +222,7 @@ "smarttub", "smhi", "sms", + "snapcast", "solaredge", "solarlog", "soma", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d8c83396d6e1a..143d5606475e5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1118,6 +1118,9 @@ smarthab==0.21 # homeassistant.components.smhi smhi-pkg==1.0.13 +# homeassistant.components.snapcast +snapcast==2.1.3 + # homeassistant.components.solaredge solaredge==0.0.2 diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py new file mode 100644 index 00000000000000..bd4dabbdcd2490 --- /dev/null +++ b/tests/components/snapcast/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Snapcast integration.""" + +from unittest.mock import AsyncMock + + +def create_mock_snapcast() -> AsyncMock: + """Create mock snapcast connection.""" + mock_connection = AsyncMock() + mock_connection.start = AsyncMock(return_value=None) + return mock_connection diff --git a/tests/components/snapcast/test_init.py b/tests/components/snapcast/test_init.py new file mode 100644 index 00000000000000..e816414dc5adad --- /dev/null +++ b/tests/components/snapcast/test_init.py @@ -0,0 +1,61 @@ +"""Test the Snapcast module.""" + +import socket +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import create_mock_snapcast + +TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + + +async def test_success(hass: HomeAssistant) -> None: + """Test successful connection.""" + 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 not result["errors"] + + mock_connection = create_mock_snapcast() + + with patch( + "snapcast.control.create_server", + return_value=mock_connection, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert ( + result["title"] == f"{TEST_CONNECTION[CONF_HOST]}:{TEST_CONNECTION[CONF_PORT]}" + ) + assert result["data"] == TEST_CONNECTION + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_error(hass: HomeAssistant) -> None: + """Test what happens when there is no server to connect.""" + 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 not result["errors"] + + with patch("snapcast.control.create_server", side_effect=socket.gaierror): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown_error"} From 878c4eab44e37d8fe7a08b86da9ca411f02ca6e7 Mon Sep 17 00:00:00 2001 From: Barrett Lowe Date: Sat, 1 May 2021 17:06:49 +0000 Subject: [PATCH 2/2] fix linting errors --- homeassistant/components/snapcast/__init__.py | 1 - .../components/snapcast/config_flow.py | 4 +-- .../components/snapcast/media_player.py | 33 ++++++++++--------- tests/components/snapcast/test_init.py | 24 ++++++++++++-- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 069e50a6ead944..85dd02ee54ac72 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -20,7 +20,6 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Snapcast from a config entry.""" - host = entry.data["host"] port = entry.data["port"] try: diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index 809c0e304afa5a..48ec75688c034b 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -29,7 +29,7 @@ class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle first step.""" - def _show_form(errors={}): + def _show_form(errors=None): return self.async_show_form( step_id="user", data_schema=SNAPCAST_SCHEMA, @@ -63,7 +63,7 @@ def _show_form(errors={}): _LOGGER.error( "Could not connect to a Snapcast Server at the provided address" ) - _LOGGER.error(f"Error: {errors['base']}") + _LOGGER.error("Error: %s", (errors["base"])) return _show_form(errors=errors) return self.async_create_entry( diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 82ef8d509b1151..6866c1ceca2305 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -3,7 +3,7 @@ import socket from typing import Callable -import snapcast.control +import snapcast from snapcast.control.server import CONTROL_PORT, Snapserver import voluptuous as vol @@ -76,8 +76,8 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable ) -> bool: """Set up the snapcast config entry.""" - theServer: Snapserver = hass.data[DOMAIN][config_entry.entry_id] - _LOGGER.debug(theServer) + the_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id] + _LOGGER.debug(the_server) register_services() @@ -85,18 +85,18 @@ async def async_setup_entry( port = config_entry.data["port"] hpid = f"{host}:{port}" - groups = [SnapcastGroupDevice(group, hpid) for group in theServer.groups] - clients = [SnapcastClientDevice(client, hpid) for client in theServer.clients] - devices = groups + clients - hass.data[DATA_KEY] = devices - async_add_entities(devices) + groups = [SnapcastGroupDevice(group, hpid) for group in the_server.groups] + clients = [SnapcastClientDevice(client, hpid) for client in the_server.clients] + hass.data[DATA_KEY]["clients"] = clients + hass.data[DATA_KEY]["groups"] = groups + async_add_entities(clients) + async_add_entities(groups) return False async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Snapcast platform.""" - host = config.get(CONF_HOST) port = config.get(CONF_PORT, CONTROL_PORT) @@ -115,9 +115,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= groups = [SnapcastGroupDevice(group, hpid) for group in server.groups] clients = [SnapcastClientDevice(client, hpid) for client in server.clients] - devices = groups + clients - hass.data[DATA_KEY] = devices - async_add_entities(devices) + hass.data[DATA_KEY]["clients"] = clients + hass.data[DATA_KEY]["groups"] = groups + async_add_entities(clients) + async_add_entities(groups) async def handle_async_join(entity, service_call): @@ -268,7 +269,7 @@ def name(self): def source(self): """Return the current input source.""" stream = self._client.group.stream - _LOGGER.debug(f"Client source queried - {self._client.identifier}:{stream}") + _LOGGER.debug("Client source queried - %s:%s", self._client.identifier, stream) return stream @property @@ -341,9 +342,10 @@ async def async_set_volume_level(self, volume): async def async_join(self, master): """Join the group of the master player.""" - master_entity = next( - entity for entity in self.hass.data[DATA_KEY] if entity.entity_id == master + entity + for entity in self.hass.data[DATA_KEY]["clients"] + if entity.entity_id == master ) if not isinstance(master_entity, SnapcastClientDevice): raise ValueError("Master is not a client device. Can only join clients.") @@ -353,7 +355,6 @@ async def async_join(self, master): for group in self._client.groups_available() if master_entity.identifier in group.clients ) - _LOGGER.debug(f"master group: {master_group}") await master_group.add_client(self._client.identifier) self.async_write_ha_state() diff --git a/tests/components/snapcast/test_init.py b/tests/components/snapcast/test_init.py index e816414dc5adad..cb3434cc79bab0 100644 --- a/tests/components/snapcast/test_init.py +++ b/tests/components/snapcast/test_init.py @@ -41,7 +41,7 @@ async def test_success(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_error(hass: HomeAssistant) -> None: +async def test_unknown_error(hass: HomeAssistant) -> None: """Test what happens when there is no server to connect.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -58,4 +58,24 @@ async def test_error(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == "form" - assert result["errors"] == {"base": "unknown_error"} + assert result["errors"] == {"base": "unknown"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test what happens when there is no server to connect.""" + 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 not result["errors"] + + with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"}