Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion homeassistant/components/snapcast/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
"""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
72 changes: 72 additions & 0 deletions homeassistant/components/snapcast/config_flow.py
Original file line number Diff line number Diff line change
@@ -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=None):
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("Error: %s", (errors["base"]))
return _show_form(errors=errors)

return self.async_create_entry(
title=f"{host}:{port}",
data=user_input,
)
4 changes: 4 additions & 0 deletions homeassistant/components/snapcast/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@

ATTR_MASTER = "master"
ATTR_LATENCY = "latency"

DEFAULT_PORT = 1705

DOMAIN = "snapcast"
17 changes: 10 additions & 7 deletions homeassistant/components/snapcast/manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 61 additions & 17 deletions homeassistant/components/snapcast/media_player.py
Original file line number Diff line number Diff line change
@@ -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
import snapcast
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 (
Expand All @@ -29,6 +32,7 @@
CLIENT_PREFIX,
CLIENT_SUFFIX,
DATA_KEY,
DOMAIN,
GROUP_PREFIX,
GROUP_SUFFIX,
SERVICE_JOIN,
Expand All @@ -44,20 +48,16 @@
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(
{vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port}
)


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")
Expand All @@ -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."""
the_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id]
_LOGGER.debug(the_server)

register_services()

host = config_entry.data["host"]
port = config_entry.data["port"]
hpid = f"{host}:{port}"

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)

register_services()

try:
server = await snapcast.control.create_server(
hass.loop, host, port, reconnect=True
Expand All @@ -84,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):
Expand Down Expand Up @@ -174,6 +206,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()
Expand Down Expand Up @@ -231,7 +268,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("Client source queried - %s:%s", self._client.identifier, stream)
return stream

@property
def volume_level(self):
Expand All @@ -257,7 +296,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
Expand Down Expand Up @@ -299,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.")
Expand Down
18 changes: 18 additions & 0 deletions homeassistant/components/snapcast/strings.json
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
18 changes: 18 additions & 0 deletions homeassistant/components/snapcast/translations/en.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"smarttub",
"smhi",
"sms",
"snapcast",
"solaredge",
"solarlog",
"soma",
Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tests/components/snapcast/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading