"documentation": "https://music-assistant.io/player-support/sendspin/",
"codeowners": ["@music-assistant"],
"credits": ["[Sendspin](https://sendspin-audio.com)"],
- "requirements": ["aiosendspin==2.0.0", "av==16.1.0"],
+ "requirements": ["aiosendspin==3.0.0", "av==16.1.0"],
"builtin": true,
"allow_disable": false
}
)
self._attr_expose_to_ha_by_default = not self.is_web_player
- async def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
+ def event_cb(self, client: SendspinClient, event: ClientEvent) -> None:
"""Event callback registered to the sendspin server."""
self.logger.debug("Received PlayerEvent: %s", event)
match event:
case MediaCommand.UNSHUFFLE if queue:
await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False)
- async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
+ def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
"""Event callback registered to the sendspin group this player belongs to."""
if self.synced_to is not None:
# Only handle group events as the leader, except for:
match event:
case GroupCommandEvent(command=command):
self.logger.debug("Group command received: %s", command)
- await self._handle_group_command(command)
+ self.mass.create_task(self._handle_group_command(command))
case GroupStateChangedEvent(state=state):
self.logger.debug("Group state changed to: %s", state)
match state:
self.update_state()
case GroupMemberRemovedEvent(client_id=client_id):
self.logger.debug("Group member removed: %s", client_id)
- if client_id == self.player_id:
- if len(self._attr_group_members) > 0:
- # We were just removed as a leader:
- # 1. stop playback on the old group
- await group.stop()
- # 2. clear our members (since we are now alone)
- group_members = [
- member for member in self._attr_group_members if member != client_id
- ]
- self._attr_group_members = []
- # 3. assign new leader if there are members left
- if len(group_members) > 0 and (
- new_leader := self.mass.players.get(group_members[0])
- ):
- new_leader = cast("SendspinPlayer", new_leader)
- new_leader._attr_group_members = group_members[1:]
- new_leader.api.disconnect_behaviour = DisconnectBehaviour.STOP
- new_leader.update_state()
- self.update_state()
- elif client_id in self._attr_group_members:
- # Someone else left our group
- self._attr_group_members.remove(client_id)
- self.update_state()
+ self.mass.create_task(self._handle_member_removed(group, client_id))
case GroupDeletedEvent():
pass
+ async def _handle_member_removed(self, group: SendspinGroup, client_id: str) -> None:
+ """Handle group member removed event asynchronously."""
+ if client_id == self.player_id:
+ if len(self._attr_group_members) > 0:
+ # We were just removed as a leader:
+ # 1. stop playback on the old group
+ await group.stop()
+ # 2. clear our members (since we are now alone)
+ group_members = [
+ member for member in self._attr_group_members if member != client_id
+ ]
+ self._attr_group_members = []
+ # 3. assign new leader if there are members left
+ if len(group_members) > 0 and (
+ new_leader := self.mass.players.get(group_members[0])
+ ):
+ new_leader = cast("SendspinPlayer", new_leader)
+ new_leader._attr_group_members = group_members[1:]
+ new_leader.api.disconnect_behaviour = DisconnectBehaviour.STOP
+ new_leader.update_state()
+ self.update_state()
+ elif client_id in self._attr_group_members:
+ # Someone else left our group
+ self._attr_group_members.remove(client_id)
+ self.update_state()
+
async def volume_set(self, volume_level: int) -> None:
"""Handle VOLUME_SET command on the player."""
if player_client := self.api.player:
self.server_api.add_event_listener(self.event_cb),
]
- async def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
+ def event_cb(self, server: SendspinServer, event: SendspinEvent) -> None:
"""Event callback registered to the sendspin server."""
self.logger.debug("Received SendspinEvent: %s", event)
match event:
case ClientAddedEvent(client_id):
- # Wait for any pending unregister to complete before registering
- # This prevents a race condition where a slow unregister removes
- # a newly registered player after a quick reconnect
- if pending_event := self._pending_unregisters.get(client_id):
- self.logger.debug(
- "Waiting for pending unregister of %s before registering", client_id
- )
- await pending_event.wait()
- player = SendspinPlayer(self, client_id)
- self.logger.debug("Client %s connected", client_id)
- if player.device_info.manufacturer == "ESPHome" and (
- hass := self.mass.get_provider("hass")
- ):
- # Try to get device name from Home Assistant for ESPHome devices
- hass = cast("HomeAssistantProvider", hass)
- if hass_device := await hass.get_device_by_connection(client_id):
- player._attr_name = (
- hass_device["name_by_user"] or hass_device["name"] or player.name
- )
- await self.mass.players.register(player)
+ self.mass.create_task(self._handle_client_added(client_id))
case ClientRemovedEvent(client_id):
- self.logger.debug("Client %s disconnected", client_id)
- unregister_event = asyncio.Event()
- self._pending_unregisters[client_id] = unregister_event
- try:
- await self.mass.players.unregister(client_id)
- finally:
- self._pending_unregisters.pop(client_id, None)
- unregister_event.set()
+ self.mass.create_task(self._handle_client_removed(client_id))
case _:
self.logger.error("Unknown sendspin event: %s", event)
+ async def _handle_client_added(self, client_id: str) -> None:
+ """Handle client added event asynchronously."""
+ # Wait for any pending unregister to complete before registering
+ # This prevents a race condition where a slow unregister removes
+ # a newly registered player after a quick reconnect
+ if pending_event := self._pending_unregisters.get(client_id):
+ self.logger.debug("Waiting for pending unregister of %s before registering", client_id)
+ await pending_event.wait()
+ # Check if client still exists (may have disconnected while waiting)
+ if self.server_api.get_client(client_id) is None:
+ self.logger.debug("Client %s gone after waiting for pending unregister", client_id)
+ return
+ player = SendspinPlayer(self, client_id)
+ self.logger.debug("Client %s connected", client_id)
+ if player.device_info.manufacturer == "ESPHome" and (
+ hass := self.mass.get_provider("hass")
+ ):
+ # Try to get device name from Home Assistant for ESPHome devices
+ hass = cast("HomeAssistantProvider", hass)
+ if hass_device := await hass.get_device_by_connection(client_id):
+ player._attr_name = (
+ hass_device["name_by_user"] or hass_device["name"] or player.name
+ )
+ await self.mass.players.register(player)
+
+ async def _handle_client_removed(self, client_id: str) -> None:
+ """Handle client removed event asynchronously."""
+ self.logger.debug("Client %s disconnected", client_id)
+ unregister_event = asyncio.Event()
+ self._pending_unregisters[client_id] = unregister_event
+ try:
+ await self.mass.players.unregister(client_id)
+ finally:
+ self._pending_unregisters.pop(client_id, None)
+ unregister_event.set()
+
@property
def supported_features(self) -> set[ProviderFeature]:
"""Return the features supported by this Provider."""
aiomusiccast==0.15.0
aiortc>=1.6.0
aiorun==2025.1.1
-aiosendspin==2.0.0
+aiosendspin==3.0.0
aioslimproto==3.1.4
aiosonos==0.1.9
aiosqlite==0.22.1