Add config-flow to Snapcast (#80288)

* initial stab at snapcast config flow

* fix linting errors

* Fix linter errors

* Add import flow, support unloading

* Add test for import flow

* Add dataclass and remove unique ID in config-flow

* remove translations

* Apply suggestions from code review

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Refactor config flow and terminate connection

* Rename test_config_flow.py

* Fix tests

* Minor fixes

* Make mock_create_server a fixture

* Combine tests

* Abort if entry already exists

* Apply suggestions from code review

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Move HomeAssistantSnapcast to own file. Clean-up last commit

* Split import flow from user flow. Fix tests.

* Use explicit asserts. Add default values to dataclass

* Change entry title to Snapcast

---------

Co-authored-by: Barrett Lowe <barrett.lowe@gmail.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
luar123 2023-03-30 07:42:09 +02:00 committed by GitHub
parent fc78290e2f
commit f0710bae06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 352 additions and 34 deletions

View file

@ -1101,7 +1101,9 @@ omit =
homeassistant/components/sms/notify.py
homeassistant/components/sms/sensor.py
homeassistant/components/smtp/notify.py
homeassistant/components/snapcast/*
homeassistant/components/snapcast/__init__.py
homeassistant/components/snapcast/media_player.py
homeassistant/components/snapcast/server.py
homeassistant/components/snmp/device_tracker.py
homeassistant/components/snmp/sensor.py
homeassistant/components/snmp/switch.py

View file

@ -1105,6 +1105,7 @@ build.json @home-assistant/supervisor
/tests/components/smhi/ @gjohansson-ST
/homeassistant/components/sms/ @ocalvo
/homeassistant/components/snapcast/ @luar123
/tests/components/snapcast/ @luar123
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck

View file

@ -1 +1,41 @@
"""The snapcast component."""
"""Snapcast Integration."""
import logging
import snapcast.control
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 .const import DOMAIN, PLATFORMS
from .server import HomeAssistantSnapcast
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Snapcast from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
try:
server = await snapcast.control.create_server(
hass.loop, host, port, reconnect=True
)
except OSError as ex:
raise ConfigEntryNotReady(
f"Could not connect to Snapcast server at {host}:{port}"
) from ex
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,63 @@
"""Snapcast config flow."""
from __future__ import annotations
import logging
import socket
import snapcast.control
from snapcast.control.server import CONTROL_PORT
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from .const import DEFAULT_TITLE, DOMAIN
_LOGGER = logging.getLogger(__name__)
SNAPCAST_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=CONTROL_PORT): int,
}
)
class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN):
"""Snapcast config flow."""
async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle first step."""
errors = {}
if user_input:
self._async_abort_entries_match(user_input)
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
# 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=False
)
except socket.gaierror:
errors["base"] = "invalid_host"
except OSError:
errors["base"] = "cannot_connect"
else:
await client.stop()
return self.async_create_entry(title=DEFAULT_TITLE, data=user_input)
return self.async_show_form(
step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors
)
async def async_step_import(self, import_config: dict[str, str]) -> FlowResult:
"""Import a config entry from configuration.yaml."""
self._async_abort_entries_match(
{
CONF_HOST: (import_config[CONF_HOST]),
CONF_PORT: (import_config[CONF_PORT]),
}
)
return self.async_create_entry(title=DEFAULT_TITLE, data=import_config)

View file

@ -1,6 +1,7 @@
"""Constants for Snapcast."""
from homeassistant.const import Platform
DATA_KEY = "snapcast"
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
GROUP_PREFIX = "snapcast_group_"
GROUP_SUFFIX = "Snapcast Group"
@ -15,3 +16,6 @@ SERVICE_SET_LATENCY = "set_latency"
ATTR_MASTER = "master"
ATTR_LATENCY = "latency"
DOMAIN = "snapcast"
DEFAULT_TITLE = "Snapcast"

View file

@ -2,6 +2,7 @@
"domain": "snapcast",
"name": "Snapcast",
"codeowners": ["@luar123"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/snapcast",
"iot_class": "local_polling",
"loggers": ["construct", "snapcast"],

View file

@ -2,9 +2,7 @@
from __future__ import annotations
import logging
import socket
import snapcast.control
from snapcast.control.server import CONTROL_PORT
import voluptuous as vol
@ -14,10 +12,12 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@ -25,7 +25,7 @@ from .const import (
ATTR_MASTER,
CLIENT_PREFIX,
CLIENT_SUFFIX,
DATA_KEY,
DOMAIN,
GROUP_PREFIX,
GROUP_SUFFIX,
SERVICE_JOIN,
@ -34,6 +34,7 @@ from .const import (
SERVICE_SNAPSHOT,
SERVICE_UNJOIN,
)
from .server import HomeAssistantSnapcast
_LOGGER = logging.getLogger(__name__)
@ -42,18 +43,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> 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.async_get_current_platform()
platform.async_register_entity_service(SERVICE_SNAPSHOT, {}, "snapshot")
platform.async_register_entity_service(SERVICE_RESTORE, {}, "async_restore")
platform.async_register_entity_service(
@ -66,23 +59,55 @@ async def async_setup_platform(
handle_set_latency,
)
try:
server = 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
# Note: Host part is needed, when using multiple snapservers
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the snapcast config entry."""
snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id]
register_services()
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
hpid = f"{host}:{port}"
devices: list[MediaPlayerEntity] = [
SnapcastGroupDevice(group, hpid) for group in server.groups
snapcast_data.groups = [
SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups
]
devices.extend(SnapcastClientDevice(client, hpid) for client in server.clients)
hass.data[DATA_KEY] = devices
async_add_entities(devices)
snapcast_data.clients = [
SnapcastClientDevice(client, hpid, config_entry.entry_id)
for client in snapcast_data.server.clients
]
async_add_entities(snapcast_data.clients + snapcast_data.groups)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Snapcast platform."""
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2023.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
async def handle_async_join(entity, service_call):
@ -211,10 +236,11 @@ class SnapcastClientDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(self, client, uid_part):
def __init__(self, client, uid_part, entry_id):
"""Initialize the Snapcast client device."""
self._client = client
self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
self._entry_id = entry_id
async def async_added_to_hass(self) -> None:
"""Subscribe to client events."""
@ -303,9 +329,10 @@ class SnapcastClientDevice(MediaPlayerEntity):
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[DOMAIN][self._entry_id].clients
if entity.entity_id == master
)
if not isinstance(master_entity, SnapcastClientDevice):
raise TypeError("Master is not a client device. Can only join clients.")

View file

@ -0,0 +1,15 @@
"""Snapcast Integration."""
from dataclasses import dataclass, field
from snapcast.control import Snapserver
from homeassistant.components.media_player import MediaPlayerEntity
@dataclass
class HomeAssistantSnapcast:
"""Snapcast data stored in the Home Assistant data object."""
server: Snapserver
clients: list[MediaPlayerEntity] = field(default_factory=list)
groups: list[MediaPlayerEntity] = field(default_factory=list)

View file

@ -0,0 +1,27 @@
{
"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"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
}
},
"issues": {
"deprecated_yaml": {
"title": "The Snapcast YAML configuration is being removed",
"description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View file

@ -399,6 +399,7 @@ FLOWS = {
"smarttub",
"smhi",
"sms",
"snapcast",
"snooz",
"solaredge",
"solarlog",

View file

@ -5061,7 +5061,7 @@
"snapcast": {
"name": "Snapcast",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"snips": {

View file

@ -1687,6 +1687,9 @@ smart-meter-texas==0.4.7
# homeassistant.components.smhi
smhi-pkg==1.0.16
# homeassistant.components.snapcast
snapcast==2.3.2
# homeassistant.components.sonos
soco==0.29.1

View file

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

View file

@ -0,0 +1,23 @@
"""Test the snapcast config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.snapcast.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_create_server() -> Generator[AsyncMock, None, None]:
"""Create mock snapcast connection."""
mock_connection = AsyncMock()
mock_connection.start = AsyncMock(return_value=None)
with patch("snapcast.control.create_server", return_value=mock_connection):
yield mock_connection

View file

@ -0,0 +1,110 @@
"""Test the Snapcast module."""
import socket
from unittest.mock import AsyncMock, patch
import pytest
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 homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705}
pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server")
async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock
) -> None:
"""Test we get the form and handle errors and 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"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
# test invalid host error
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"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "invalid_host"}
# test connection error
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"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
# test success
result = await hass.config_entries.flow.async_configure(
result["flow_id"], TEST_CONNECTION
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Snapcast"
assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705}
assert len(mock_create_server.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_abort(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock
) -> None:
"""Test config flow abort if device is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
data=TEST_CONNECTION,
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
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"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import(hass: HomeAssistant) -> None:
"""Test successful import."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=TEST_CONNECTION,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Snapcast"
assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705}