Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1f4ec26
initial stab at snapcast config flow
BarrettLowe May 1, 2021
878c4ea
fix linting errors
BarrettLowe May 1, 2021
0046e98
Merge remote-tracking branch 'BarrettLowe/snapcast_config_flow' into …
luar123 Oct 13, 2022
7140820
Fix linter errors
luar123 Oct 13, 2022
45610fa
Add import flow, support unloading
luar123 Oct 15, 2022
9d6e529
Add test for import flow
luar123 Oct 15, 2022
8d50595
Add dataclass and remove unique ID in config-flow
luar123 Oct 18, 2022
84f493c
Merge remote-tracking branch 'upstream/dev' into snapcast_configflow
luar123 Feb 18, 2023
4b52865
remove translations
luar123 Mar 8, 2023
411fa78
Merge remote-tracking branch 'upstream/dev' into snapcast_configflow
luar123 Mar 8, 2023
b9dbc5b
Apply suggestions from code review
luar123 Mar 23, 2023
b9a57d2
Refactor config flow and terminate connection
luar123 Mar 23, 2023
7c97460
Rename test_config_flow.py
luar123 Mar 23, 2023
ec67248
Fix tests
luar123 Mar 23, 2023
50580e9
Minor fixes
luar123 Mar 23, 2023
d520968
Make mock_create_server a fixture
luar123 Mar 24, 2023
5ac9af4
Combine tests
luar123 Mar 24, 2023
dec6cda
Merge remote-tracking branch 'upstream/dev' into snapcast_configflow
luar123 Mar 25, 2023
cb62c3f
Abort if entry already exists
luar123 Mar 25, 2023
69a902f
Apply suggestions from code review
luar123 Mar 28, 2023
2d3ad78
Move HomeAssistantSnapcast to own file. Clean-up last commit
luar123 Mar 28, 2023
7df4d7a
Split import flow from user flow. Fix tests.
luar123 Mar 28, 2023
a1ec736
Use explicit asserts. Add default values to dataclass
luar123 Mar 29, 2023
8304c14
Change entry title to Snapcast
luar123 Mar 29, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1102,7 +1102,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
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,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
Expand Down
42 changes: 41 additions & 1 deletion homeassistant/components/snapcast/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
"""The snapcast component."""
"""Snapcast Integration."""
import logging

import snapcast.control

from homeassistant.config_entries import ConfigEntry
Comment thread
luar123 marked this conversation as resolved.
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

Comment thread
luar123 marked this conversation as resolved.
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 changes: 63 additions & 0 deletions homeassistant/components/snapcast/config_flow.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion homeassistant/components/snapcast/const.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -15,3 +16,6 @@

ATTR_MASTER = "master"
ATTR_LATENCY = "latency"

DOMAIN = "snapcast"
DEFAULT_TITLE = "Snapcast"
1 change: 1 addition & 0 deletions homeassistant/components/snapcast/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
87 changes: 57 additions & 30 deletions homeassistant/components/snapcast/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -14,18 +12,20 @@
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 (
ATTR_LATENCY,
ATTR_MASTER,
CLIENT_PREFIX,
CLIENT_SUFFIX,
DATA_KEY,
DOMAIN,
GROUP_PREFIX,
GROUP_SUFFIX,
SERVICE_JOIN,
Expand All @@ -34,6 +34,7 @@
SERVICE_SNAPSHOT,
SERVICE_UNJOIN,
)
from .server import HomeAssistantSnapcast

_LOGGER = logging.getLogger(__name__)

Expand All @@ -42,18 +43,10 @@
)


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(
Expand All @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -303,9 +329,10 @@ async def async_set_volume_level(self, volume: float) -> None:

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.")
Expand Down
15 changes: 15 additions & 0 deletions homeassistant/components/snapcast/server.py
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions homeassistant/components/snapcast/strings.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@
"smarttub",
"smhi",
"sms",
"snapcast",
"snooz",
"solaredge",
"solarlog",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -5061,7 +5061,7 @@
"snapcast": {
"name": "Snapcast",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"snips": {
Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,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

Expand Down
1 change: 1 addition & 0 deletions tests/components/snapcast/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Snapcast integration."""
Loading