Skip to content

Commit

Permalink
Fix: Sonos Airplay mode
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt committed Nov 20, 2024
1 parent 6a9bce5 commit 2a90217
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 95 deletions.
14 changes: 8 additions & 6 deletions music_assistant/providers/airplay/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@
content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
)

IGNORE_RAOP_SONOS_MODELS = (
BROKEN_RAOP_MODELS = (
# A recent fw update of newer gen Sonos speakers block RAOP (airplay 1) support,
# basically rendering our airplay implementation useless on these devices.
# This list contains the models that are known to have this issue.
# Hopefully the issue won't spread to other models.
"Era 100",
"Era 300",
"Move 2",
"Roam 2",
"Arc Ultra",
("Sonos", "Era 100"),
("Sonos", "Era 300"),
("Sonos", "Move 2"),
("Sonos", "Roam 2"),
("Sonos", "Arc Ultra"),
# Samsung has been repeatedly being reported as having issues with AirPlay 1/raop
("Samsung", "*"),
)
64 changes: 50 additions & 14 deletions music_assistant/providers/airplay/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from zeroconf import IPVersion

from music_assistant.providers.airplay.const import BROKEN_RAOP_MODELS

if TYPE_CHECKING:
from zeroconf.asyncio import AsyncServiceInfo

Expand All @@ -20,23 +22,49 @@ def convert_airplay_volume(value: float) -> int:
return int(portion + normal_min)


def get_model_from_am(am_property: str | None) -> tuple[str, str]:
"""Return Manufacturer and Model name from mdns AM property."""
manufacturer = "Unknown"
model = "Generic Airplay device"
if not am_property:
def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]:
"""Return Manufacturer and Model name from mdns info."""
manufacturer = info.decoded_properties.get("manufacturer")
model = info.decoded_properties.get("model")
if manufacturer and model:
return (manufacturer, model)
if isinstance(am_property, bytes):
am_property = am_property.decode("utf-8")
if am_property == "AudioAccessory5,1":
model = "HomePod"
manufacturer = "Apple"
elif "AppleTV" in am_property:
# try parse from am property
if am_property := info.decoded_properties.get("am"):
if isinstance(am_property, bytes):
am_property = am_property.decode("utf-8")
model = am_property

if not model:
model = "Unknown"

# parse apple model names
if model == "AudioAccessory6,1":
return ("Apple", "HomePod 2")
if model in ("AudioAccessory5,1", "AudioAccessorySingle5,1"):
return ("Apple", "HomePod Mini")
if model == "AppleTV1,1":
return ("Apple", "Apple TV Gen1")
if model == "AppleTV2,1":
return ("Apple", "Apple TV Gen2")
if model in ("AppleTV3,1", "AppleTV3,2"):
return ("Apple", "Apple TV Gen3")
if model == "AppleTV5,3":
return ("Apple", "Apple TV Gen4")
if model == "AppleTV6,2":
return ("Apple", "Apple TV 4K")
if model == "AppleTV11,1":
return ("Apple", "Apple TV 4K Gen2")
if model == "AppleTV14,1":
return ("Apple", "Apple TV 4K Gen3")
if "AirPort" in model:
return ("Apple", "AirPort Express")
if "AudioAccessory" in model:
return ("Apple", "HomePod")
if "AppleTV" in model:
model = "Apple TV"
manufacturer = "Apple"
else:
model = am_property
return (manufacturer, model)

return (manufacturer or "Airplay", model)


def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None:
Expand All @@ -50,3 +78,11 @@ def get_primary_ip_address(discovery_info: AsyncServiceInfo) -> str | None:
continue
return address
return None


def is_broken_raop_model(manufacturer: str, model: str) -> bool:
"""Check if a model is known to have broken RAOP support."""
for broken_manufacturer, broken_model in BROKEN_RAOP_MODELS:
if broken_manufacturer in (manufacturer, "*") and broken_model in (model, "*"):
return True
return False
8 changes: 2 additions & 6 deletions music_assistant/providers/airplay/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@
"domain": "airplay",
"name": "Airplay",
"description": "Support for players that support the Airplay protocol.",
"codeowners": [
"@music-assistant"
],
"codeowners": ["@music-assistant"],
"requirements": [],
"documentation": "https://music-assistant.io/player-support/airplay/",
"multi_instance": false,
"builtin": false,
"icon": "cast-variant",
"mdns_discovery": [
"_raop._tcp.local."
]
"mdns_discovery": ["_raop._tcp.local."]
}
54 changes: 40 additions & 14 deletions music_assistant/providers/airplay/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@
CONF_PASSWORD,
CONF_READ_AHEAD_BUFFER,
FALLBACK_VOLUME,
IGNORE_RAOP_SONOS_MODELS,
)
from .helpers import convert_airplay_volume, get_model_from_am, get_primary_ip_address
from .helpers import (
convert_airplay_volume,
get_model_info,
get_primary_ip_address,
is_broken_raop_model,
)
from .player import AirPlayPlayer

if TYPE_CHECKING:
Expand Down Expand Up @@ -113,6 +117,14 @@
create_sample_rates_config_entry(44100, 16, 44100, 16, True),
)

BROKEN_RAOP_WARN = ConfigEntry(
key="broken_raop",
type=ConfigEntryType.ALERT,
default_value=None,
required=False,
label="This player is known to have broken Airplay 1 (RAOP) support. "
"Playback may fail or simply be silent. There is no workaround for this issue at the moment.",
)

# TODO: Airplay provider
# - Implement authentication for Apple TV
Expand Down Expand Up @@ -168,7 +180,13 @@ async def on_mdns_service_state_change(
self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
) -> None:
"""Handle MDNS service state callback."""
raw_id, display_name = name.split(".")[0].split("@", 1)
if "@" in name:
raw_id, display_name = name.split(".")[0].split("@", 1)
elif "deviceid" in info.decoded_properties:
raw_id = info.decoded_properties["deviceid"].replace(":", "")
display_name = info.name.split(".")[0]
else:
return
player_id = f"ap{raw_id.lower()}"
# handle removed player
if state_change == ServiceStateChange.Removed:
Expand Down Expand Up @@ -219,6 +237,9 @@ async def unload(self) -> None:
async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]:
"""Return all (provider/player specific) Config Entries for the given player (if any)."""
base_entries = await super().get_player_config_entries(player_id)
if player := self.mass.players.get(player_id):
if is_broken_raop_model(player.device_info.manufacturer, player.device_info.model):
return (*base_entries, BROKEN_RAOP_WARN, *PLAYER_CONFIG_ENTRIES)
return (*base_entries, *PLAYER_CONFIG_ENTRIES)

async def cmd_stop(self, player_id: str) -> None:
Expand Down Expand Up @@ -450,9 +471,18 @@ async def _setup_player(
if address is None:
return
self.logger.debug("Discovered Airplay device %s on %s", display_name, address)
manufacturer, model = get_model_from_am(info.decoded_properties.get("am"))

default_enabled = not info.server.startswith("Sonos-")
# prefer airplay mdns info as it has more details
# fallback to raop info if airplay info is not available
airplay_info = AsyncServiceInfo(
"_airplay._tcp.local.", info.name.split("@")[-1].replace("_raop", "_airplay")
)
if await airplay_info.async_request(self.mass.aiozc.zeroconf, 3000):
manufacturer, model = get_model_info(airplay_info)
else:
manufacturer, model = get_model_info(info)

default_enabled = not is_broken_raop_model(manufacturer, model)
if not self.mass.config.get_raw_player_config_value(player_id, "enabled", default_enabled):
self.logger.debug("Ignoring %s in discovery as it is disabled.", display_name)
return
Expand All @@ -465,15 +495,11 @@ async def _setup_player(
"Ignoring %s in discovery because it is not yet supported.", display_name
)
return
if model in IGNORE_RAOP_SONOS_MODELS:
# for now completely ignore the sonos models that have broken RAOP support
# its very much unlikely that this will ever be fixed by Sonos
# revisit this once/if we have support for airplay 2.
self.logger.info(
"Ignoring %s in discovery as it is a known Sonos model with broken RAOP support.",
display_name,
)
return

# append airplay to the default display name for generic (non-apple) devices
# this makes it easier for users to distinguish between airplay and non-airplay devices
if manufacturer.lower() != "apple" and "airplay" not in display_name.lower():
display_name += " (Airplay)"

self._players[player_id] = AirPlayPlayer(self, player_id, info, address)
if not (volume := await self.mass.cache.get(player_id, base_key=CACHE_KEY_PREV_VOLUME)):
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/providers/sonos/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "SONOS",
"description": "SONOS Player provider for Music Assistant.",
"codeowners": ["@music-assistant"],
"requirements": ["aiosonos==0.1.6"],
"requirements": ["aiosonos==0.1.7"],
"documentation": "https://music-assistant.io/player-support/sonos/",
"multi_instance": false,
"builtin": false,
Expand Down
64 changes: 24 additions & 40 deletions music_assistant/providers/sonos/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,21 @@ def __init__(
self.queue_version: str = shortuuid.random(8)
self._on_cleanup_callbacks: list[Callable[[], None]] = []

def get_linked_airplay_player(
self, enabled_only: bool = True, active_only: bool = False
) -> Player | None:
@property
def airplay_mode_enabled(self) -> bool:
"""Return if airplay mode is enabled for the player."""
return self.mass.config.get_raw_player_config_value(
self.player_id, CONF_AIRPLAY_MODE, False
)

def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None:
"""Return the linked airplay player if available/enabled."""
if enabled_only and not self.mass.config.get_raw_player_config_value(
self.player_id, CONF_AIRPLAY_MODE
):
if enabled_only and not self.airplay_mode_enabled:
return None
if not (airplay_player := self.mass.players.get(self.airplay_player_id)):
return None
if not airplay_player.available:
return None
if active_only and not airplay_player.powered and not airplay_player.group_childs:
return None
return airplay_player

async def setup(self) -> None:
Expand All @@ -106,6 +107,8 @@ async def setup(self) -> None:
supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
if not self.client.player.has_fixed_volume:
supported_features.add(PlayerFeature.VOLUME_SET)
if not self.get_linked_airplay_player(False):
supported_features.add(PlayerFeature.NEXT_PREVIOUS)

# instantiate the MA player
self.mass_player = mass_player = Player(
Expand Down Expand Up @@ -177,9 +180,7 @@ async def cmd_stop(self) -> None:
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
if (
airplay := self.get_linked_airplay_player(True, True)
) and airplay.state != PlayerState.IDLE:
if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
# linked airplay player is active, redirect the command
self.logger.debug("Redirecting STOP command to linked airplay player.")
if player_provider := self.mass.get_provider(airplay.provider):
Expand All @@ -196,9 +197,7 @@ async def cmd_play(self) -> None:
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
if (
airplay := self.get_linked_airplay_player(True, True)
) and airplay.state != PlayerState.IDLE:
if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
# linked airplay player is active, redirect the command
self.logger.debug("Redirecting PLAY command to linked airplay player.")
if player_provider := self.mass.get_provider(airplay.provider):
Expand All @@ -211,9 +210,7 @@ async def cmd_pause(self) -> None:
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
if (
airplay := self.get_linked_airplay_player(True, True)
) and airplay.state != PlayerState.IDLE:
if (airplay := self.get_linked_airplay_player(True)) and airplay.state != PlayerState.IDLE:
# linked airplay player is active, redirect the command
self.logger.debug("Redirecting PAUSE command to linked airplay player.")
if player_provider := self.mass.get_provider(airplay.provider):
Expand Down Expand Up @@ -267,28 +264,6 @@ def update_attributes(self) -> None: # noqa: PLR0915
self.mass_player.synced_to = active_group.coordinator_id
self.mass_player.active_source = active_group.coordinator_id

if airplay := self.get_linked_airplay_player(True, True):
# linked airplay player is active, update media from there
self.mass_player.state = airplay.state
self.mass_player.powered = airplay.powered
self.mass_player.active_source = airplay.active_source
self.mass_player.elapsed_time = airplay.elapsed_time
self.mass_player.elapsed_time_last_updated = airplay.elapsed_time_last_updated
# mark 'next_previous' feature as unsupported when airplay mode is active
if PlayerFeature.NEXT_PREVIOUS in self.mass_player.supported_features:
self.mass_player.supported_features = (
x
for x in self.mass_player.supported_features
if x != PlayerFeature.NEXT_PREVIOUS
)
return
# ensure 'next_previous' feature is supported when airplay mode is not active
if PlayerFeature.NEXT_PREVIOUS not in self.mass_player.supported_features:
self.mass_player.supported_features = (
*self.mass_player.supported_features,
PlayerFeature.NEXT_PREVIOUS,
)

# map playback state
self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state]
self.mass_player.elapsed_time = active_group.position
Expand All @@ -301,12 +276,21 @@ def update_attributes(self) -> None: # noqa: PLR0915
self.mass_player.active_source = SOURCE_LINE_IN
elif container_type == ContainerType.AIRPLAY:
# check if the MA airplay player is active
airplay_player = self.mass.players.get(self.airplay_player_id)
airplay_player = self.get_linked_airplay_player(False)
if airplay_player and airplay_player.state in (
PlayerState.PLAYING,
PlayerState.PAUSED,
):
self.mass_player.state = airplay_player.state
self.mass_player.powered = True
self.mass_player.active_source = airplay_player.active_source
self.mass_player.elapsed_time = airplay_player.elapsed_time
self.mass_player.elapsed_time_last_updated = (
airplay_player.elapsed_time_last_updated
)
self.mass_player.current_media = airplay_player.current_media
# return early as we dont need further info
return
else:
self.mass_player.active_source = SOURCE_AIRPLAY
elif container_type == ContainerType.STATION:
Expand Down
Loading

0 comments on commit 2a90217

Please sign in to comment.