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:
parent
fc78290e2f
commit
f0710bae06
15 changed files with 352 additions and 34 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
63
homeassistant/components/snapcast/config_flow.py
Normal file
63
homeassistant/components/snapcast/config_flow.py
Normal 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)
|
|
@ -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"
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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.")
|
||||
|
|
15
homeassistant/components/snapcast/server.py
Normal file
15
homeassistant/components/snapcast/server.py
Normal 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)
|
27
homeassistant/components/snapcast/strings.json
Normal file
27
homeassistant/components/snapcast/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -399,6 +399,7 @@ FLOWS = {
|
|||
"smarttub",
|
||||
"smhi",
|
||||
"sms",
|
||||
"snapcast",
|
||||
"snooz",
|
||||
"solaredge",
|
||||
"solarlog",
|
||||
|
|
|
@ -5061,7 +5061,7 @@
|
|||
"snapcast": {
|
||||
"name": "Snapcast",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"snips": {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/snapcast/__init__.py
Normal file
1
tests/components/snapcast/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Snapcast integration."""
|
23
tests/components/snapcast/conftest.py
Normal file
23
tests/components/snapcast/conftest.py
Normal 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
|
110
tests/components/snapcast/test_config_flow.py
Normal file
110
tests/components/snapcast/test_config_flow.py
Normal 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}
|
Loading…
Add table
Reference in a new issue