Fix Snapcast connection issues (#93010)
* Add (dis)connect and update listeners, terminate connection and reconnect. Set availability * Pass entry_id to constructorpull/93443/head
parent
a43dcaf812
commit
869f970e59
|
@ -27,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
f"Could not connect to Snapcast server at {host}:{port}"
|
f"Could not connect to Snapcast server at {host}:{port}"
|
||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server)
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(
|
||||||
|
hass, server, f"{host}:{port}", entry.entry_id
|
||||||
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
@ -37,5 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
snapcast_data = hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
# disconnect from server
|
||||||
|
await snapcast_data.disconnect()
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from snapcast.control.server import CONTROL_PORT
|
from snapcast.control.server import CONTROL_PORT, Snapserver
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
|
@ -34,7 +34,6 @@ from .const import (
|
||||||
SERVICE_SNAPSHOT,
|
SERVICE_SNAPSHOT,
|
||||||
SERVICE_UNJOIN,
|
SERVICE_UNJOIN,
|
||||||
)
|
)
|
||||||
from .server import HomeAssistantSnapcast
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -72,7 +71,7 @@ async def async_setup_entry(
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the snapcast config entry."""
|
"""Set up the snapcast config entry."""
|
||||||
snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id]
|
snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server
|
||||||
|
|
||||||
register_services()
|
register_services()
|
||||||
|
|
||||||
|
@ -80,14 +79,18 @@ async def async_setup_entry(
|
||||||
port = config_entry.data[CONF_PORT]
|
port = config_entry.data[CONF_PORT]
|
||||||
hpid = f"{host}:{port}"
|
hpid = f"{host}:{port}"
|
||||||
|
|
||||||
snapcast_data.groups = [
|
groups: list[MediaPlayerEntity] = [
|
||||||
SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups
|
SnapcastGroupDevice(group, hpid, config_entry.entry_id)
|
||||||
|
for group in snapcast_server.groups
|
||||||
]
|
]
|
||||||
snapcast_data.clients = [
|
clients: list[MediaPlayerEntity] = [
|
||||||
SnapcastClientDevice(client, hpid, config_entry.entry_id)
|
SnapcastClientDevice(client, hpid, config_entry.entry_id)
|
||||||
for client in snapcast_data.server.clients
|
for client in snapcast_server.clients
|
||||||
]
|
]
|
||||||
async_add_entities(snapcast_data.clients + snapcast_data.groups)
|
async_add_entities(clients + groups)
|
||||||
|
hass.data[DOMAIN][
|
||||||
|
config_entry.entry_id
|
||||||
|
].hass_async_add_entities = async_add_entities
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
async def async_setup_platform(
|
||||||
|
@ -147,18 +150,27 @@ class SnapcastGroupDevice(MediaPlayerEntity):
|
||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, group, uid_part):
|
def __init__(self, group, uid_part, entry_id):
|
||||||
"""Initialize the Snapcast group device."""
|
"""Initialize the Snapcast group device."""
|
||||||
|
self._attr_available = True
|
||||||
self._group = group
|
self._group = group
|
||||||
|
self._entry_id = entry_id
|
||||||
self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
|
self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}"
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to group events."""
|
"""Subscribe to group events."""
|
||||||
self._group.set_callback(self.schedule_update_ha_state)
|
self._group.set_callback(self.schedule_update_ha_state)
|
||||||
|
self.hass.data[DOMAIN][self._entry_id].groups.append(self)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Disconnect group object when removed."""
|
"""Disconnect group object when removed."""
|
||||||
self._group.set_callback(None)
|
self._group.set_callback(None)
|
||||||
|
self.hass.data[DOMAIN][self._entry_id].groups.remove(self)
|
||||||
|
|
||||||
|
def set_availability(self, available: bool) -> None:
|
||||||
|
"""Set availability of group."""
|
||||||
|
self._attr_available = available
|
||||||
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> MediaPlayerState | None:
|
def state(self) -> MediaPlayerState | None:
|
||||||
|
@ -172,6 +184,11 @@ class SnapcastGroupDevice(MediaPlayerEntity):
|
||||||
"""Return the ID of snapcast group."""
|
"""Return the ID of snapcast group."""
|
||||||
return self._uid
|
return self._uid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
"""Return the snapcast identifier."""
|
||||||
|
return self._group.identifier
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device."""
|
"""Return the name of the device."""
|
||||||
|
@ -236,6 +253,7 @@ class SnapcastClientDevice(MediaPlayerEntity):
|
||||||
|
|
||||||
def __init__(self, client, uid_part, entry_id):
|
def __init__(self, client, uid_part, entry_id):
|
||||||
"""Initialize the Snapcast client device."""
|
"""Initialize the Snapcast client device."""
|
||||||
|
self._attr_available = True
|
||||||
self._client = client
|
self._client = client
|
||||||
self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
|
self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}"
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
|
@ -243,10 +261,17 @@ class SnapcastClientDevice(MediaPlayerEntity):
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Subscribe to client events."""
|
"""Subscribe to client events."""
|
||||||
self._client.set_callback(self.schedule_update_ha_state)
|
self._client.set_callback(self.schedule_update_ha_state)
|
||||||
|
self.hass.data[DOMAIN][self._entry_id].clients.append(self)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Disconnect client object when removed."""
|
"""Disconnect client object when removed."""
|
||||||
self._client.set_callback(None)
|
self._client.set_callback(None)
|
||||||
|
self.hass.data[DOMAIN][self._entry_id].clients.remove(self)
|
||||||
|
|
||||||
|
def set_availability(self, available: bool) -> None:
|
||||||
|
"""Set availability of group."""
|
||||||
|
self._attr_available = available
|
||||||
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
|
|
|
@ -1,15 +1,141 @@
|
||||||
"""Snapcast Integration."""
|
"""Snapcast Integration."""
|
||||||
from dataclasses import dataclass, field
|
from __future__ import annotations
|
||||||
|
|
||||||
from snapcast.control import Snapserver
|
import logging
|
||||||
|
|
||||||
|
import snapcast.control
|
||||||
|
from snapcast.control.client import Snapclient
|
||||||
|
|
||||||
from homeassistant.components.media_player import MediaPlayerEntity
|
from homeassistant.components.media_player import MediaPlayerEntity
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .media_player import SnapcastClientDevice, SnapcastGroupDevice
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HomeAssistantSnapcast:
|
class HomeAssistantSnapcast:
|
||||||
"""Snapcast data stored in the Home Assistant data object."""
|
"""Snapcast server and data stored in the Home Assistant data object."""
|
||||||
|
|
||||||
server: Snapserver
|
hass: HomeAssistant
|
||||||
clients: list[MediaPlayerEntity] = field(default_factory=list)
|
|
||||||
groups: list[MediaPlayerEntity] = field(default_factory=list)
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
server: snapcast.control.Snapserver,
|
||||||
|
hpid: str,
|
||||||
|
entry_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the HomeAssistantSnapcast object.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
hass: HomeAssistant
|
||||||
|
hass object
|
||||||
|
server : snapcast.control.Snapserver
|
||||||
|
Snapcast server
|
||||||
|
hpid : str
|
||||||
|
host and port
|
||||||
|
entry_id: str
|
||||||
|
ConfigEntry entry_id
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
None
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.hass: HomeAssistant = hass
|
||||||
|
self.server: snapcast.control.Snapserver = server
|
||||||
|
self.hpid: str = hpid
|
||||||
|
self._entry_id = entry_id
|
||||||
|
self.clients: list[SnapcastClientDevice] = []
|
||||||
|
self.groups: list[SnapcastGroupDevice] = []
|
||||||
|
self.hass_async_add_entities: AddEntitiesCallback
|
||||||
|
# connect callbacks
|
||||||
|
self.server.set_on_update_callback(self.on_update)
|
||||||
|
self.server.set_on_connect_callback(self.on_connect)
|
||||||
|
self.server.set_on_disconnect_callback(self.on_disconnect)
|
||||||
|
self.server.set_new_client_callback(self.on_add_client)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Disconnect from server."""
|
||||||
|
self.server.set_on_update_callback(None)
|
||||||
|
self.server.set_on_connect_callback(None)
|
||||||
|
self.server.set_on_disconnect_callback(None)
|
||||||
|
self.server.set_new_client_callback(None)
|
||||||
|
await self.server.stop()
|
||||||
|
|
||||||
|
def on_update(self) -> None:
|
||||||
|
"""Update all entities.
|
||||||
|
|
||||||
|
Retrieve all groups/clients from server and add/update/delete entities.
|
||||||
|
"""
|
||||||
|
if not self.hass_async_add_entities:
|
||||||
|
return
|
||||||
|
new_groups: list[MediaPlayerEntity] = []
|
||||||
|
groups: list[MediaPlayerEntity] = []
|
||||||
|
hass_groups = {g.identifier: g for g in self.groups}
|
||||||
|
for group in self.server.groups:
|
||||||
|
if group.identifier in hass_groups:
|
||||||
|
groups.append(hass_groups[group.identifier])
|
||||||
|
hass_groups[group.identifier].async_schedule_update_ha_state()
|
||||||
|
else:
|
||||||
|
new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id))
|
||||||
|
new_clients: list[MediaPlayerEntity] = []
|
||||||
|
clients: list[MediaPlayerEntity] = []
|
||||||
|
hass_clients = {c.identifier: c for c in self.clients}
|
||||||
|
for client in self.server.clients:
|
||||||
|
if client.identifier in hass_clients:
|
||||||
|
clients.append(hass_clients[client.identifier])
|
||||||
|
hass_clients[client.identifier].async_schedule_update_ha_state()
|
||||||
|
else:
|
||||||
|
new_clients.append(
|
||||||
|
SnapcastClientDevice(client, self.hpid, self._entry_id)
|
||||||
|
)
|
||||||
|
del_entities: list[MediaPlayerEntity] = [
|
||||||
|
x for x in self.groups if x not in groups
|
||||||
|
]
|
||||||
|
del_entities.extend([x for x in self.clients if x not in clients])
|
||||||
|
|
||||||
|
_LOGGER.debug("New clients: %s", str(new_clients))
|
||||||
|
_LOGGER.debug("New groups: %s", str(new_groups))
|
||||||
|
_LOGGER.debug("Delete: %s", str(del_entities))
|
||||||
|
|
||||||
|
ent_reg = er.async_get(self.hass)
|
||||||
|
for entity in del_entities:
|
||||||
|
ent_reg.async_remove(entity.entity_id)
|
||||||
|
self.hass_async_add_entities(new_clients + new_groups)
|
||||||
|
|
||||||
|
def on_connect(self) -> None:
|
||||||
|
"""Activate all entities and update."""
|
||||||
|
for client in self.clients:
|
||||||
|
client.set_availability(True)
|
||||||
|
for group in self.groups:
|
||||||
|
group.set_availability(True)
|
||||||
|
_LOGGER.info("Server connected: %s", self.hpid)
|
||||||
|
self.on_update()
|
||||||
|
|
||||||
|
def on_disconnect(self, ex: Exception | None) -> None:
|
||||||
|
"""Deactivate all entities."""
|
||||||
|
for client in self.clients:
|
||||||
|
client.set_availability(False)
|
||||||
|
for group in self.groups:
|
||||||
|
group.set_availability(False)
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "")
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_add_client(self, client: Snapclient) -> None:
|
||||||
|
"""Add a Snapcast client.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
client : Snapclient
|
||||||
|
Snapcast client to be added to HA.
|
||||||
|
"""
|
||||||
|
if not self.hass_async_add_entities:
|
||||||
|
return
|
||||||
|
clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)]
|
||||||
|
self.hass_async_add_entities(clients)
|
||||||
|
|
Loading…
Reference in New Issue