return StreamingProtocol.AIRPLAY2
return StreamingProtocol.RAOP
+ @property
+ def available(self) -> bool:
+ """Return if the player is currently available."""
+ if self._requires_pairing():
+ # check if we have credentials stored
+ credentials = self.config.get_value(CONF_AP_CREDENTIALS)
+ if not credentials:
+ return False
+ return super().available
+
async def get_config_entries(
self,
action: str | None = None,
"""Return all (provider/player specific) Config Entries for the given player (if any)."""
base_entries = await super().get_config_entries()
+ require_pairing = self._requires_pairing()
+
# Handle pairing actions
- if action and self._requires_pairing():
+ if action and require_pairing:
await self._handle_pairing_action(action=action, values=values)
# Add pairing config entries for Apple TV and macOS devices
- if self._requires_pairing():
- base_entries = [*self._get_pairing_config_entries(), *base_entries]
+ if require_pairing:
+ base_entries = [*self._get_pairing_config_entries(values), *base_entries]
- base_entries = await super().get_config_entries(action=action, values=values)
+ # Regular AirPlay config entries
base_entries += [
CONF_ENTRY_FLOW_MODE_ENFORCED,
CONF_ENTRY_DEPRECATED_EQ_BASS,
),
]
- # Regular AirPlay config entries
- base_entries.extend(
- [
- ConfigEntry(
- key=CONF_ENCRYPTION,
- type=ConfigEntryType.BOOLEAN,
- default_value=True,
- label="Enable encryption",
- description="Enable encrypted communication with the player, "
- "should by default be enabled for most devices.",
- category="airplay",
- ),
- ConfigEntry(
- key=CONF_ALAC_ENCODE,
- type=ConfigEntryType.BOOLEAN,
- default_value=True,
- label="Enable compression",
- description="Save some network bandwidth by sending the audio as "
- "(lossless) ALAC at the cost of a bit CPU.",
- category="airplay",
- ),
- CONF_ENTRY_SYNC_ADJUST,
- ConfigEntry(
- key=CONF_PASSWORD,
- type=ConfigEntryType.SECURE_STRING,
- default_value=None,
- required=False,
- label="Device password",
- description="Some devices require a password to connect/play.",
- category="airplay",
- ),
- ConfigEntry(
- key=CONF_READ_AHEAD_BUFFER,
- type=ConfigEntryType.INTEGER,
- default_value=1000,
- required=False,
- label="Audio buffer (ms)",
- description="Amount of buffer (in milliseconds), "
- "the player should keep to absorb network throughput jitter. "
- "If you experience audio dropouts, try increasing this value.",
- category="airplay",
- range=(500, 3000),
- ),
- # airplay has fixed sample rate/bit depth so make this config entry
- # static and hidden
- create_sample_rates_config_entry(
- supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
- ),
- ConfigEntry(
- key=CONF_IGNORE_VOLUME,
- type=ConfigEntryType.BOOLEAN,
- default_value=False,
- label="Ignore volume reports sent by the device itself",
- description=(
- "The AirPlay protocol allows devices to report their own volume "
- "level. \n"
- "For some devices this is not reliable and can cause unexpected "
- "volume changes. \n"
- "Enable this option to ignore these reports."
- ),
- category="airplay",
- ),
- ]
- )
-
if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model):
base_entries.insert(-1, BROKEN_RAOP_WARN)
# Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio)
return model.startswith(("Mac", "iMac"))
- def _get_pairing_config_entries(self) -> list[ConfigEntry]:
+ def _get_pairing_config_entries(
+ self, values: dict[str, ConfigValueType] | None
+ ) -> list[ConfigEntry]:
"""Return pairing config entries for Apple TV and macOS devices.
Uses cliraop for AirPlay/RAOP pairing.
entries: list[ConfigEntry] = []
# Check if we have credentials stored
- has_credentials = bool(self.config.get_value(CONF_AP_CREDENTIALS))
+ if values and (creds := values.get(CONF_AP_CREDENTIALS)):
+ credentials = str(creds)
+ else:
+ credentials = str(self.config.get_value(CONF_AP_CREDENTIALS) or "")
+ has_credentials = bool(credentials)
if not has_credentials:
# Show pairing instructions and start button
- entries.append(
- ConfigEntry(
- key="pairing_instructions",
- type=ConfigEntryType.LABEL,
- label="AirPlay Pairing Required",
- description=(
- "This device requires pairing before it can be used. "
- "Click the button below to start the pairing process."
- ),
+ if not self.stream and self.protocol == StreamingProtocol.RAOP:
+ # ensure we have a stream instance to track pairing state
+ from .protocols.raop import RaopStream # noqa: PLC0415
+
+ self.stream = RaopStream(self)
+ elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
+ # ensure we have a stream instance to track pairing state
+ from .protocols.airplay2 import AirPlay2Stream # noqa: PLC0415
+
+ self.stream = AirPlay2Stream(self)
+ if self.stream and not self.stream.supports_pairing:
+ # TEMP until ap2 pairing is implemented
+ return [
+ ConfigEntry(
+ key="pairing_unsupported",
+ type=ConfigEntryType.ALERT,
+ label=(
+ "This device requires pairing but it is not supported "
+ "by the current Music Assistant AirPlay implementation."
+ ),
+ )
+ ]
+
+ # If pairing was started, show PIN entry
+ if self.stream and self.stream.is_pairing:
+ entries.append(
+ ConfigEntry(
+ key=CONF_PAIRING_PIN,
+ type=ConfigEntryType.STRING,
+ label="Enter the 4-digit PIN shown on the device",
+ required=True,
+ )
)
- )
- entries.append(
- ConfigEntry(
- key=CONF_ACTION_START_PAIRING,
- type=ConfigEntryType.ACTION,
- label="Start Pairing",
- description="Start the AirPlay pairing process",
- action=CONF_ACTION_START_PAIRING,
+ entries.append(
+ ConfigEntry(
+ key=CONF_ACTION_FINISH_PAIRING,
+ type=ConfigEntryType.ACTION,
+ label="Complete the pairing process with the PIN",
+ action=CONF_ACTION_FINISH_PAIRING,
+ )
+ )
+ else:
+ entries.append(
+ ConfigEntry(
+ key="pairing_instructions",
+ type=ConfigEntryType.LABEL,
+ label=(
+ "This device requires pairing before it can be used. "
+ "Click the button below to start the pairing process."
+ ),
+ )
+ )
+ entries.append(
+ ConfigEntry(
+ key=CONF_ACTION_START_PAIRING,
+ type=ConfigEntryType.ACTION,
+ label="Start the AirPlay pairing process",
+ action=CONF_ACTION_START_PAIRING,
+ )
)
- )
else:
# Show paired status
entries.append(
ConfigEntry(
key="pairing_status",
type=ConfigEntryType.LABEL,
- label="AirPlay Pairing Status",
- description="Device is paired and ready to use.",
- )
- )
-
- # If pairing was started, show PIN entry
- if self.config.get_value("_pairing_in_progress"):
- entries.append(
- ConfigEntry(
- key=CONF_PAIRING_PIN,
- type=ConfigEntryType.STRING,
- label="Enter PIN",
- description="Enter the 4-digit PIN shown on the device",
- required=True,
- )
- )
- entries.append(
- ConfigEntry(
- key=CONF_ACTION_FINISH_PAIRING,
- type=ConfigEntryType.ACTION,
- label="Finish Pairing",
- description="Complete the pairing process with the PIN",
- action=CONF_ACTION_FINISH_PAIRING,
+ label="Device is paired and ready to use.",
)
)
key=CONF_AP_CREDENTIALS,
type=ConfigEntryType.SECURE_STRING,
label="AirPlay Credentials",
- default_value=None,
+ default_value=credentials,
+ value=credentials,
required=False,
hidden=True,
)
async def _handle_pairing_action(
self, action: str, values: dict[str, ConfigValueType] | None
) -> None:
- """Handle pairing actions using cliraop.
+ """Handle pairing actions using the configured protocol."""
+ if not self.stream and self.protocol == StreamingProtocol.RAOP:
+ # ensure we have a stream instance to track pairing state
+ from .protocols.raop import RaopStream # noqa: PLC0415
- TODO: Implement actual cliraop-based pairing.
- """
+ self.stream = RaopStream(self)
+ elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
+ # ensure we have a stream instance to track pairing state
+ from .protocols.airplay2 import AirPlay2Stream # noqa: PLC0415
+
+ self.stream = AirPlay2Stream(self)
if action == CONF_ACTION_START_PAIRING:
- # TODO: Start pairing using cliraop
- # For now, just set a flag to show the PIN entry
- self.mass.config.set_raw_player_config_value(
- self.player_id, "_pairing_in_progress", True
- )
+ if self.stream and self.stream.is_pairing:
+ self.logger.warning("Pairing process already in progress for %s", self.display_name)
+ return
self.logger.info("Started AirPlay pairing for %s", self.display_name)
+ if self.stream:
+ await self.stream.start_pairing()
elif action == CONF_ACTION_FINISH_PAIRING:
- # TODO: Finish pairing using cliraop with the provided PIN
if not values:
+ # guard
return
pin = values.get(CONF_PAIRING_PIN)
self.logger.warning("No PIN provided for pairing")
return
- # TODO: Use cliraop to complete pairing with the PIN
- # For now, just clear the pairing in progress flag
- self.mass.config.set_raw_player_config_value(
- self.player_id, "_pairing_in_progress", False
- )
+ if self.stream:
+ credentials = await self.stream.finish_pairing(pin=str(pin))
+ else:
+ return
- # TODO: Store the actual credentials obtained from cliraop
- # self.mass.config.set_raw_player_config_value(
- # self.player_id, CONF_AP_CREDENTIALS, credentials_from_cliraop
- # )
+ values[CONF_AP_CREDENTIALS] = credentials
self.logger.info(
- "Finished AirPlay pairing for %s (TODO: implement actual pairing)",
+ "Finished AirPlay pairing for %s",
self.display_name,
)
if self.stream.prevent_playback:
# player is in prevent playback mode, we need to stop the stream
await self.stop()
- else:
+ elif self.stream.session:
await self.stream.session.replace_stream(audio_source)
return
if player_ids_to_remove:
if self.player_id in player_ids_to_remove:
# dissolve the entire sync group
- if self.stream and self.stream.running:
+ if self.stream and self.stream.running and self.stream.session:
# stop the stream session if it is running
await self.stream.session.stop()
self._attr_group_members = []
if (
child_player_to_add.stream
and child_player_to_add.stream.running
+ and child_player_to_add.stream.session
and child_player_to_add.stream.session != stream_session
):
await child_player_to_add.stream.session.remove_client(child_player_to_add)
await super().on_unload()
if self.stream:
# stop the stream session if it is running
- if self.stream.running:
+ if self.stream.running and self.stream.session:
self.mass.create_task(self.stream.session.stop())
self.stream = None
import asyncio
import logging
+from typing import TYPE_CHECKING, cast
from music_assistant_models.enums import PlaybackState
from music_assistant_models.errors import PlayerCommandFailed
from music_assistant.helpers.process import AsyncProcess, check_output
from music_assistant.providers.airplay.constants import (
CONF_ALAC_ENCODE,
+ CONF_AP_CREDENTIALS,
CONF_ENCRYPTION,
CONF_PASSWORD,
CONF_READ_AHEAD_BUFFER,
from ._protocol import AirPlayProtocol
+if TYPE_CHECKING:
+ from music_assistant.providers.airplay.provider import AirPlayProvider
+
class RaopStream(AirPlayProtocol):
"""
and we can send some interactive commands using a named pipe.
"""
+ supports_pairing = True
_stderr_reader_task: asyncio.Task[None] | None = None
@property
player_id, CONF_PASSWORD, None
):
extra_args += ["-password", str(device_password)]
- # Add AirPlay credentials from pyatv pairing if available (for Apple devices)
- # if raop_credentials := self.player.config.get_value(CONF_AP_CREDENTIALS):
- # # pyatv AirPlay credentials are in format "identifier:secret_key:other:data"
- # # cliraop expects just the secret_key (2nd part, 64-char hex string) for -secret
- # parts = str(raop_credentials).split(":")
- # if len(parts) >= 2:
- # # Take the second part (index 1) as the secret key
- # secret_key = parts[1]
- # self.prov.logger.debug(
- # "Using AirPlay credentials for %s: id=%s, secret_len=%d, parts=%d",
- # self.player.player_id,
- # parts[0],
- # len(secret_key),
- # len(parts),
- # )
- # extra_args += ["-secret", secret_key]
- # else:
- # # Fallback: assume it's already just the key
- # self.prov.logger.debug(
- # "Using AirPlay credentials for %s: single value, length=%d",
- # self.player.player_id,
- # len(str(raop_credentials)),
- # )
- # extra_args += ["-secret", str(raop_credentials)]
+ # Add AirPlay credentials from pairing if available (for Apple devices)
+ if ap_credentials := self.player.config.get_value(CONF_AP_CREDENTIALS):
+ extra_args += ["-secret", str(ap_credentials)]
if self.prov.logger.isEnabledFor(logging.DEBUG):
extra_args += ["-debug", "5"]
elif self.prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
str(self.player.volume_level),
*extra_args,
"-dacp",
- self.prov.dacp_id,
+ cast("AirPlayProvider", self.prov).dacp_id,
"-activeremote",
self.active_remote_id,
"-cmdpipe",
# start reading the stderr of the cliraop process from another task
self._stderr_reader_task = self.mass.create_task(self._stderr_reader())
+ async def start_pairing(self) -> None:
+ """Start pairing process for this protocol (if supported)."""
+ assert self.player.discovery_info is not None # for type checker
+ cli_binary = await get_cli_binary(self.player.protocol)
+ cliraop_args = [
+ cli_binary,
+ "-pair",
+ "-if",
+ self.mass.streams.bind_ip,
+ "-port",
+ str(self.player.discovery_info.port),
+ "-udn",
+ self.player.discovery_info.name,
+ self.player.address,
+ self.audio_named_pipe,
+ ]
+ self.player.logger.debug(
+ "Starting PAIRING with cliraop process for player %s with args: %s",
+ self.player.player_id,
+ cliraop_args,
+ )
+ self._cli_proc = AsyncProcess(cliraop_args, stdin=True, stderr=True, name="cliraop")
+ await self._cli_proc.start()
+ # read up to first 10 lines of stderr to get the initial status
+ for _ in range(10):
+ line = (await self._cli_proc.read_stderr()).decode("utf-8", errors="ignore")
+ self.player.logger.debug(line)
+ if "enter PIN code displayed on " in line:
+ self.is_pairing = True
+ return
+ await self._cli_proc.close()
+ raise PlayerCommandFailed("Pairing failed")
+
+ async def finish_pairing(self, pin: str) -> str:
+ """Finish pairing process with given PIN (if supported)."""
+ if not self.is_pairing:
+ await self.start_pairing()
+ if not self._cli_proc or self._cli_proc.closed:
+ raise PlayerCommandFailed("Pairing process not started")
+
+ self.is_pairing = False
+ _, _stderr = await self._cli_proc.communicate(input=f"{pin}\n".encode(), timeout=10)
+ for line in _stderr.decode().splitlines():
+ self.player.logger.debug(line)
+ for error in ("device did not respond", "can't authentify", "pin failed"):
+ if error in line.lower():
+ raise PlayerCommandFailed(f"Pairing failed: {error}")
+ if "secret is " in line:
+ return line.split("secret is ")[1].strip()
+ raise PlayerCommandFailed(f"Pairing failed: {_stderr.decode().strip()}")
+
async def _stderr_reader(self) -> None:
"""Monitor stderr for the running CLIRaop process."""
player = self.player