mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
Yamaha musiccast grouping-services (#51952)
Co-authored-by: Tom Schneider <tom.schneider-github@sutomaji.net>
This commit is contained in:
parent
8255a2f6c8
commit
8133793f23
@ -65,6 +65,7 @@ class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]):
|
||||
self.musiccast = client
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||
self.entities: list[MusicCastDeviceEntity] = []
|
||||
|
||||
async def _async_update_data(self) -> MusicCastData:
|
||||
"""Update data via library."""
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Constants for the MusicCast integration."""
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
REPEAT_MODE_ALL,
|
||||
REPEAT_MODE_OFF,
|
||||
@ -16,6 +17,9 @@ ATTR_MODEL = "model"
|
||||
ATTR_PLAYLIST = "playlist"
|
||||
ATTR_PRESET = "preset"
|
||||
ATTR_SOFTWARE_VERSION = "sw_version"
|
||||
ATTR_MC_LINK = "mc_link"
|
||||
ATTR_MAIN_SYNC = "main_sync"
|
||||
ATTR_MC_LINK_SOURCES = [ATTR_MC_LINK, ATTR_MAIN_SYNC]
|
||||
|
||||
DEFAULT_ZONE = "main"
|
||||
HA_REPEAT_MODE_TO_MC_MAPPING = {
|
||||
@ -24,6 +28,8 @@ HA_REPEAT_MODE_TO_MC_MAPPING = {
|
||||
REPEAT_MODE_ALL: "all",
|
||||
}
|
||||
|
||||
NULL_GROUP = "00000000000000000000000000000000"
|
||||
|
||||
INTERVAL_SECONDS = "interval_seconds"
|
||||
|
||||
MC_REPEAT_MODE_TO_HA_MAPPING = {
|
||||
|
@ -3,12 +3,14 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aiomusiccast import MusicCastGroupException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||
from homeassistant.components.media_player.const import (
|
||||
REPEAT_MODE_OFF,
|
||||
SUPPORT_CLEAR_PLAYLIST,
|
||||
SUPPORT_GROUPING,
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
@ -37,14 +39,18 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType
|
||||
from homeassistant.util import uuid
|
||||
|
||||
from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity
|
||||
from .const import (
|
||||
ATTR_MAIN_SYNC,
|
||||
ATTR_MC_LINK,
|
||||
DEFAULT_ZONE,
|
||||
DOMAIN,
|
||||
HA_REPEAT_MODE_TO_MC_MAPPING,
|
||||
INTERVAL_SECONDS,
|
||||
MC_REPEAT_MODE_TO_HA_MAPPING,
|
||||
NULL_GROUP,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -64,6 +70,7 @@ MUSIC_PLAYER_SUPPORT = (
|
||||
| SUPPORT_SELECT_SOUND_MODE
|
||||
| SUPPORT_SELECT_SOURCE
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_GROUPING
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
@ -146,12 +153,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
|
||||
|
||||
self._cur_track = 0
|
||||
self._repeat = REPEAT_MODE_OFF
|
||||
self.coordinator.entities.append(self)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when this Entity has been added to HA."""
|
||||
await super().async_added_to_hass()
|
||||
# Sensors should also register callbacks to HA when their state changes
|
||||
self.coordinator.musiccast.register_callback(self.async_write_ha_state)
|
||||
self.coordinator.musiccast.register_group_update_callback(
|
||||
self.update_all_mc_entities
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Entity being removed from hass."""
|
||||
@ -164,6 +175,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
|
||||
"""Push an update after each command."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def ip_address(self):
|
||||
"""Return the ip address of the musiccast device."""
|
||||
return self.coordinator.musiccast.ip
|
||||
|
||||
@property
|
||||
def zone_id(self):
|
||||
"""Return the zone id of the musiccast device."""
|
||||
return self._zone_id
|
||||
|
||||
@property
|
||||
def _is_netusb(self):
|
||||
return (
|
||||
@ -288,6 +309,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Return the image url of current playing media."""
|
||||
if self.is_client and self.group_server != self:
|
||||
return self.group_server.coordinator.musiccast.media_image_url
|
||||
return self.coordinator.musiccast.media_image_url if self._is_netusb else None
|
||||
|
||||
@property
|
||||
@ -408,3 +431,364 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
|
||||
return self.coordinator.data.netusb_play_time_updated
|
||||
|
||||
return None
|
||||
|
||||
# Group and MusicCast System specific functions/properties
|
||||
|
||||
@property
|
||||
def is_network_server(self) -> bool:
|
||||
"""Return only true if the current entity is a network server and not a main zone with an attached zone2."""
|
||||
return (
|
||||
self.coordinator.data.group_role == "server"
|
||||
and self.coordinator.data.group_id != NULL_GROUP
|
||||
and self._zone_id == self.coordinator.data.group_server_zone
|
||||
)
|
||||
|
||||
@property
|
||||
def other_zones(self) -> list[MusicCastMediaPlayer]:
|
||||
"""Return media player entities of the other zones of this device."""
|
||||
return [
|
||||
entity
|
||||
for entity in self.coordinator.entities
|
||||
if entity != self and isinstance(entity, MusicCastMediaPlayer)
|
||||
]
|
||||
|
||||
@property
|
||||
def is_server(self) -> bool:
|
||||
"""Return whether the media player is the server/host of the group.
|
||||
|
||||
If the media player is not part of a group, False is returned.
|
||||
"""
|
||||
return self.is_network_server or (
|
||||
self._zone_id == DEFAULT_ZONE
|
||||
and len(
|
||||
[
|
||||
entity
|
||||
for entity in self.other_zones
|
||||
if entity.source == ATTR_MAIN_SYNC
|
||||
]
|
||||
)
|
||||
> 0
|
||||
)
|
||||
|
||||
@property
|
||||
def is_network_client(self) -> bool:
|
||||
"""Return True if the current entity is a network client and not just a main syncing entity."""
|
||||
return (
|
||||
self.coordinator.data.group_role == "client"
|
||||
and self.coordinator.data.group_id != NULL_GROUP
|
||||
and self.source == ATTR_MC_LINK
|
||||
)
|
||||
|
||||
@property
|
||||
def is_client(self) -> bool:
|
||||
"""Return whether the media player is the client of a group.
|
||||
|
||||
If the media player is not part of a group, False is returned.
|
||||
"""
|
||||
return self.is_network_client or self.source == ATTR_MAIN_SYNC
|
||||
|
||||
def get_all_mc_entities(self) -> list[MusicCastMediaPlayer]:
|
||||
"""Return all media player entities of the musiccast system."""
|
||||
entities = []
|
||||
for coordinator in self.hass.data[DOMAIN].values():
|
||||
entities += [
|
||||
entity
|
||||
for entity in coordinator.entities
|
||||
if isinstance(entity, MusicCastMediaPlayer)
|
||||
]
|
||||
return entities
|
||||
|
||||
def get_all_server_entities(self) -> list[MusicCastMediaPlayer]:
|
||||
"""Return all media player entities in the musiccast system, which are in server mode."""
|
||||
entities = self.get_all_mc_entities()
|
||||
return [entity for entity in entities if entity.is_server]
|
||||
|
||||
def get_distribution_num(self) -> int:
|
||||
"""Return the distribution_num (number of clients in the whole musiccast system)."""
|
||||
return sum(
|
||||
[
|
||||
len(server.coordinator.data.group_client_list)
|
||||
for server in self.get_all_server_entities()
|
||||
]
|
||||
)
|
||||
|
||||
def is_part_of_group(self, group_server) -> bool:
|
||||
"""Return True if the given server is the server of self's group."""
|
||||
return group_server != self and (
|
||||
(
|
||||
self.ip_address in group_server.coordinator.data.group_client_list
|
||||
and self.coordinator.data.group_id
|
||||
== group_server.coordinator.data.group_id
|
||||
and self.ip_address != group_server.ip_address
|
||||
and self.source == ATTR_MC_LINK
|
||||
)
|
||||
or (
|
||||
self.ip_address == group_server.ip_address
|
||||
and self.source == ATTR_MAIN_SYNC
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def group_server(self):
|
||||
"""Return the server of the own group if present, self else."""
|
||||
for entity in self.get_all_server_entities():
|
||||
if self.is_part_of_group(entity):
|
||||
return entity
|
||||
return self
|
||||
|
||||
@property
|
||||
def group_members(self) -> list[str] | None:
|
||||
"""Return a list of entity_ids, which belong to the group of self."""
|
||||
return [entity.entity_id for entity in self.musiccast_group]
|
||||
|
||||
@property
|
||||
def musiccast_group(self) -> list[MusicCastMediaPlayer]:
|
||||
"""Return all media players of the current group, if the media player is server."""
|
||||
if self.is_client:
|
||||
# If we are a client we can still share group information, but we will take them from the server.
|
||||
server = self.group_server
|
||||
if server != self:
|
||||
return server.musiccast_group
|
||||
|
||||
return [self]
|
||||
if not self.is_server:
|
||||
return [self]
|
||||
entities = self.get_all_mc_entities()
|
||||
clients = [entity for entity in entities if entity.is_part_of_group(self)]
|
||||
return [self] + clients
|
||||
|
||||
@property
|
||||
def musiccast_zone_entity(self) -> MusicCastMediaPlayer:
|
||||
"""Return the the entity of the zone, which is using MusicCast at the moment, if there is one, self else.
|
||||
|
||||
It is possible that multiple zones use MusicCast as client at the same time. In this case the first one is
|
||||
returned.
|
||||
"""
|
||||
for entity in self.other_zones:
|
||||
if entity.is_network_server or entity.is_network_client:
|
||||
return entity
|
||||
|
||||
return self
|
||||
|
||||
async def update_all_mc_entities(self):
|
||||
"""Update the whole musiccast system when group data change."""
|
||||
for entity in self.get_all_mc_entities():
|
||||
if entity.is_server:
|
||||
await entity.async_check_client_list()
|
||||
entity.async_write_ha_state()
|
||||
|
||||
# Services
|
||||
|
||||
async def async_join_players(self, group_members):
|
||||
"""Add all clients given in entities to the group of the server.
|
||||
|
||||
Creates a new group if necessary. Used for join service.
|
||||
"""
|
||||
_LOGGER.info(
|
||||
"%s wants to add the following entities %s",
|
||||
self.entity_id,
|
||||
str(group_members),
|
||||
)
|
||||
|
||||
entities = [
|
||||
entity
|
||||
for entity in self.get_all_mc_entities()
|
||||
if entity.entity_id in group_members
|
||||
]
|
||||
|
||||
if not self.is_server and self.musiccast_zone_entity.is_server:
|
||||
# The MusicCast Distribution Module of this device is already in use. To use it as a server, we first
|
||||
# have to unjoin and wait until the servers are updated.
|
||||
await self.musiccast_zone_entity.async_server_close_group()
|
||||
elif self.musiccast_zone_entity.is_client:
|
||||
await self.async_client_leave_group(True)
|
||||
# Use existing group id if we are server, generate a new one else.
|
||||
group = (
|
||||
self.coordinator.data.group_id
|
||||
if self.is_server
|
||||
else uuid.random_uuid_hex().upper()
|
||||
)
|
||||
# First let the clients join
|
||||
for client in entities:
|
||||
if client != self:
|
||||
try:
|
||||
await client.async_client_join(group, self)
|
||||
except MusicCastGroupException:
|
||||
_LOGGER.warning(
|
||||
"%s is struggling to update its group data. Will retry perform the update",
|
||||
client.entity_id,
|
||||
)
|
||||
await client.async_client_join(group, self)
|
||||
|
||||
await self.coordinator.musiccast.mc_server_group_extend(
|
||||
self._zone_id,
|
||||
[
|
||||
entity.ip_address
|
||||
for entity in entities
|
||||
if entity.ip_address != self.ip_address
|
||||
],
|
||||
group,
|
||||
self.get_distribution_num(),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s added the following entities %s", self.entity_id, str(entities)
|
||||
)
|
||||
_LOGGER.info(
|
||||
"%s has now the following musiccast group %s",
|
||||
self.entity_id,
|
||||
str(self.musiccast_group),
|
||||
)
|
||||
|
||||
await self.update_all_mc_entities()
|
||||
|
||||
async def async_unjoin_player(self):
|
||||
"""Leave the group.
|
||||
|
||||
Stops the distribution if device is server. Used for unjoin service.
|
||||
"""
|
||||
_LOGGER.debug("%s called service unjoin", self.entity_id)
|
||||
if self.is_server:
|
||||
await self.async_server_close_group()
|
||||
|
||||
else:
|
||||
await self.async_client_leave_group()
|
||||
|
||||
await self.update_all_mc_entities()
|
||||
|
||||
# Internal client functions
|
||||
|
||||
async def async_client_join(self, group_id, server):
|
||||
"""Let the client join a group.
|
||||
|
||||
If this client is a server, the server will stop distributing. If the client is part of a different group,
|
||||
it will leave that group first.
|
||||
"""
|
||||
# If we should join the group, which is served by the main zone, we can simply select main_sync as input.
|
||||
_LOGGER.debug("%s called service client join", self.entity_id)
|
||||
if self.state == STATE_OFF:
|
||||
await self.async_turn_on()
|
||||
if self.ip_address == server.ip_address:
|
||||
if server.zone == DEFAULT_ZONE:
|
||||
await self.async_select_source(ATTR_MAIN_SYNC)
|
||||
server.async_write_ha_state()
|
||||
return
|
||||
|
||||
# It is not possible to join a group hosted by zone2 from main zone.
|
||||
raise Exception("Can not join a zone other than main of the same device.")
|
||||
|
||||
if self.musiccast_zone_entity.is_server:
|
||||
# If one of the zones of the device is a server, we need to unjoin first.
|
||||
_LOGGER.info(
|
||||
"%s is a server of a group and has to stop distribution "
|
||||
"to use MusicCast for %s",
|
||||
self.musiccast_zone_entity.entity_id,
|
||||
self.entity_id,
|
||||
)
|
||||
await self.musiccast_zone_entity.async_server_close_group()
|
||||
|
||||
elif self.is_client:
|
||||
if self.coordinator.data.group_id == server.coordinator.data.group_id:
|
||||
_LOGGER.warning("%s is already part of the group", self.entity_id)
|
||||
return
|
||||
|
||||
_LOGGER.info(
|
||||
"%s is client in a different group, will unjoin first",
|
||||
self.entity_id,
|
||||
)
|
||||
await self.async_client_leave_group()
|
||||
|
||||
elif (
|
||||
self.ip_address in server.coordinator.data.group_client_list
|
||||
and self.coordinator.data.group_id == server.coordinator.data.group_id
|
||||
and self.coordinator.data.group_role == "client"
|
||||
):
|
||||
# The device is already part of this group (e.g. main zone is also a client of this group).
|
||||
# Just select mc_link as source
|
||||
await self.async_select_source(ATTR_MC_LINK)
|
||||
# As the musiccast group has changed, we need to trigger the servers ha state.
|
||||
# In other cases this happens due to the callback after the dist updated message.
|
||||
server.async_write_ha_state()
|
||||
return
|
||||
|
||||
_LOGGER.debug("%s will now join as a client", self.entity_id)
|
||||
await self.coordinator.musiccast.mc_client_join(
|
||||
server.ip_address, group_id, self._zone_id
|
||||
)
|
||||
|
||||
# Ensure that mc link is selected. If main sync was selected previously, it's possible that this does not
|
||||
# happen automatically
|
||||
await self.async_select_source(ATTR_MC_LINK)
|
||||
|
||||
async def async_client_leave_group(self, force=False):
|
||||
"""Make self leave the group.
|
||||
|
||||
Should only be called for clients.
|
||||
"""
|
||||
_LOGGER.debug("%s client leave called", self.entity_id)
|
||||
if not force and (
|
||||
self.source == ATTR_MAIN_SYNC
|
||||
or len(
|
||||
[entity for entity in self.other_zones if entity.source == ATTR_MC_LINK]
|
||||
)
|
||||
> 0
|
||||
):
|
||||
# If we are only syncing to main or another zone is also using the musiccast module as client, don't
|
||||
# kill the client session, just select a dummy source.
|
||||
save_inputs = self.coordinator.musiccast.get_save_inputs(self._zone_id)
|
||||
if len(save_inputs):
|
||||
await self.async_select_source(save_inputs[0])
|
||||
# Then turn off the zone
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
servers = [
|
||||
server
|
||||
for server in self.get_all_server_entities()
|
||||
if server.coordinator.data.group_id == self.coordinator.data.group_id
|
||||
]
|
||||
await self.coordinator.musiccast.mc_client_unjoin()
|
||||
if len(servers):
|
||||
await servers[0].coordinator.musiccast.mc_server_group_reduce(
|
||||
servers[0].zone_id, [self.ip_address], self.get_distribution_num()
|
||||
)
|
||||
|
||||
for server in self.get_all_server_entities():
|
||||
await server.async_check_client_list()
|
||||
|
||||
# Internal server functions
|
||||
|
||||
async def async_server_close_group(self):
|
||||
"""Close group of self.
|
||||
|
||||
Should only be called for servers.
|
||||
"""
|
||||
_LOGGER.info("%s closes his group", self.entity_id)
|
||||
for client in self.musiccast_group:
|
||||
if client != self:
|
||||
await client.async_client_leave_group()
|
||||
await self.coordinator.musiccast.mc_server_group_close()
|
||||
|
||||
async def async_check_client_list(self):
|
||||
"""Let the server check if all its clients are still part of his group."""
|
||||
_LOGGER.debug("%s updates his group members", self.entity_id)
|
||||
client_ips_for_removal = []
|
||||
for expected_client_ip in self.coordinator.data.group_client_list:
|
||||
if expected_client_ip not in [
|
||||
entity.ip_address for entity in self.musiccast_group
|
||||
]:
|
||||
# The client is no longer part of the group. Prepare removal.
|
||||
client_ips_for_removal.append(expected_client_ip)
|
||||
|
||||
if len(client_ips_for_removal) > 0:
|
||||
_LOGGER.info(
|
||||
"%s says good bye to the following members %s",
|
||||
self.entity_id,
|
||||
str(client_ips_for_removal),
|
||||
)
|
||||
await self.coordinator.musiccast.mc_server_group_reduce(
|
||||
self._zone_id, client_ips_for_removal, self.get_distribution_num()
|
||||
)
|
||||
if len(self.musiccast_group) < 2:
|
||||
# The group is empty, stop distribution.
|
||||
await self.async_server_close_group()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
Loading…
x
Reference in New Issue
Block a user