Skip to content

Commit a7de16e

Browse files
committed
Fix pairing support for AirPlay
1 parent 0182cd0 commit a7de16e

File tree

8 files changed

+188
-170
lines changed

8 files changed

+188
-170
lines changed
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
16.3 KB
Binary file not shown.

music_assistant/providers/airplay/player.py

Lines changed: 101 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,17 @@ async def get_config_entries(
130130
"""Return all (provider/player specific) Config Entries for the given player (if any)."""
131131
base_entries = await super().get_config_entries()
132132

133+
require_pairing = self._requires_pairing()
134+
133135
# Handle pairing actions
134-
if action and self._requires_pairing():
136+
if action and require_pairing:
135137
await self._handle_pairing_action(action=action, values=values)
136138

137139
# Add pairing config entries for Apple TV and macOS devices
138-
if self._requires_pairing():
139-
base_entries = [*self._get_pairing_config_entries(), *base_entries]
140+
if require_pairing:
141+
base_entries = [*self._get_pairing_config_entries(values), *base_entries]
140142

141-
base_entries = await super().get_config_entries(action=action, values=values)
143+
# Regular AirPlay config entries
142144
base_entries += [
143145
CONF_ENTRY_FLOW_MODE_ENFORCED,
144146
CONF_ENTRY_DEPRECATED_EQ_BASS,
@@ -223,71 +225,6 @@ async def get_config_entries(
223225
),
224226
]
225227

226-
# Regular AirPlay config entries
227-
base_entries.extend(
228-
[
229-
ConfigEntry(
230-
key=CONF_ENCRYPTION,
231-
type=ConfigEntryType.BOOLEAN,
232-
default_value=True,
233-
label="Enable encryption",
234-
description="Enable encrypted communication with the player, "
235-
"should by default be enabled for most devices.",
236-
category="airplay",
237-
),
238-
ConfigEntry(
239-
key=CONF_ALAC_ENCODE,
240-
type=ConfigEntryType.BOOLEAN,
241-
default_value=True,
242-
label="Enable compression",
243-
description="Save some network bandwidth by sending the audio as "
244-
"(lossless) ALAC at the cost of a bit CPU.",
245-
category="airplay",
246-
),
247-
CONF_ENTRY_SYNC_ADJUST,
248-
ConfigEntry(
249-
key=CONF_PASSWORD,
250-
type=ConfigEntryType.SECURE_STRING,
251-
default_value=None,
252-
required=False,
253-
label="Device password",
254-
description="Some devices require a password to connect/play.",
255-
category="airplay",
256-
),
257-
ConfigEntry(
258-
key=CONF_READ_AHEAD_BUFFER,
259-
type=ConfigEntryType.INTEGER,
260-
default_value=1000,
261-
required=False,
262-
label="Audio buffer (ms)",
263-
description="Amount of buffer (in milliseconds), "
264-
"the player should keep to absorb network throughput jitter. "
265-
"If you experience audio dropouts, try increasing this value.",
266-
category="airplay",
267-
range=(500, 3000),
268-
),
269-
# airplay has fixed sample rate/bit depth so make this config entry
270-
# static and hidden
271-
create_sample_rates_config_entry(
272-
supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
273-
),
274-
ConfigEntry(
275-
key=CONF_IGNORE_VOLUME,
276-
type=ConfigEntryType.BOOLEAN,
277-
default_value=False,
278-
label="Ignore volume reports sent by the device itself",
279-
description=(
280-
"The AirPlay protocol allows devices to report their own volume "
281-
"level. \n"
282-
"For some devices this is not reliable and can cause unexpected "
283-
"volume changes. \n"
284-
"Enable this option to ignore these reports."
285-
),
286-
category="airplay",
287-
),
288-
]
289-
)
290-
291228
if is_broken_raop_model(self.device_info.manufacturer, self.device_info.model):
292229
base_entries.insert(-1, BROKEN_RAOP_WARN)
293230

@@ -305,67 +242,91 @@ def _requires_pairing(self) -> bool:
305242
# Mac devices (including iMac, MacBook, Mac mini, Mac Pro, Mac Studio)
306243
return model.startswith(("Mac", "iMac"))
307244

308-
def _get_pairing_config_entries(self) -> list[ConfigEntry]:
245+
def _get_pairing_config_entries(
246+
self, values: dict[str, ConfigValueType] | None
247+
) -> list[ConfigEntry]:
309248
"""Return pairing config entries for Apple TV and macOS devices.
310249
311250
Uses cliraop for AirPlay/RAOP pairing.
312251
"""
313252
entries: list[ConfigEntry] = []
314253

315254
# Check if we have credentials stored
316-
has_credentials = bool(self.config.get_value(CONF_AP_CREDENTIALS))
255+
if values and (creds := values.get(CONF_AP_CREDENTIALS)):
256+
credentials = str(creds)
257+
else:
258+
credentials = str(self.config.get_value(CONF_AP_CREDENTIALS) or "")
259+
has_credentials = bool(credentials)
317260

318261
if not has_credentials:
319262
# Show pairing instructions and start button
320-
entries.append(
321-
ConfigEntry(
322-
key="pairing_instructions",
323-
type=ConfigEntryType.LABEL,
324-
label="AirPlay Pairing Required",
325-
description=(
326-
"This device requires pairing before it can be used. "
327-
"Click the button below to start the pairing process."
328-
),
263+
if not self.stream and self.protocol == StreamingProtocol.RAOP:
264+
# ensure we have a stream instance to track pairing state
265+
from .protocols.raop import RaopStream # noqa: PLC0415
266+
267+
self.stream = RaopStream(self)
268+
elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
269+
# ensure we have a stream instance to track pairing state
270+
from .protocols.airplay2 import AirPlay2Stream # noqa: PLC0415
271+
272+
self.stream = AirPlay2Stream(self)
273+
if self.stream and not self.stream.supports_pairing:
274+
# TEMP until ap2 pairing is implemented
275+
return [
276+
ConfigEntry(
277+
key="pairing_unsupported",
278+
type=ConfigEntryType.ALERT,
279+
label=(
280+
"This device requires pairing but it is not supported "
281+
"by the current Music Assistant AirPlay implementation."
282+
),
283+
)
284+
]
285+
286+
# If pairing was started, show PIN entry
287+
if self.stream and self.stream.is_pairing:
288+
entries.append(
289+
ConfigEntry(
290+
key=CONF_PAIRING_PIN,
291+
type=ConfigEntryType.STRING,
292+
label="Enter the 4-digit PIN shown on the device",
293+
required=True,
294+
)
329295
)
330-
)
331-
entries.append(
332-
ConfigEntry(
333-
key=CONF_ACTION_START_PAIRING,
334-
type=ConfigEntryType.ACTION,
335-
label="Start Pairing",
336-
description="Start the AirPlay pairing process",
337-
action=CONF_ACTION_START_PAIRING,
296+
entries.append(
297+
ConfigEntry(
298+
key=CONF_ACTION_FINISH_PAIRING,
299+
type=ConfigEntryType.ACTION,
300+
label="Complete the pairing process with the PIN",
301+
action=CONF_ACTION_FINISH_PAIRING,
302+
)
303+
)
304+
else:
305+
entries.append(
306+
ConfigEntry(
307+
key="pairing_instructions",
308+
type=ConfigEntryType.LABEL,
309+
label=(
310+
"This device requires pairing before it can be used. "
311+
"Click the button below to start the pairing process."
312+
),
313+
)
314+
)
315+
entries.append(
316+
ConfigEntry(
317+
key=CONF_ACTION_START_PAIRING,
318+
type=ConfigEntryType.ACTION,
319+
label="Start the AirPlay pairing process",
320+
action=CONF_ACTION_START_PAIRING,
321+
)
338322
)
339-
)
340323
else:
341324
# Show paired status
342325
entries.append(
343326
ConfigEntry(
344327
key="pairing_status",
345328
type=ConfigEntryType.LABEL,
346-
label="AirPlay Pairing Status",
347-
description="Device is paired and ready to use.",
348-
)
349-
)
350-
351-
# If pairing was started, show PIN entry
352-
if self.config.get_value("_pairing_in_progress"):
353-
entries.append(
354-
ConfigEntry(
355-
key=CONF_PAIRING_PIN,
356-
type=ConfigEntryType.STRING,
357-
label="Enter PIN",
358-
description="Enter the 4-digit PIN shown on the device",
359-
required=True,
360-
)
361-
)
362-
entries.append(
363-
ConfigEntry(
364-
key=CONF_ACTION_FINISH_PAIRING,
365-
type=ConfigEntryType.ACTION,
366-
label="Finish Pairing",
367-
description="Complete the pairing process with the PIN",
368-
action=CONF_ACTION_FINISH_PAIRING,
329+
label="Device is paired and ready to use.",
369330
)
370331
)
371332

@@ -375,7 +336,8 @@ def _get_pairing_config_entries(self) -> list[ConfigEntry]:
375336
key=CONF_AP_CREDENTIALS,
376337
type=ConfigEntryType.SECURE_STRING,
377338
label="AirPlay Credentials",
378-
default_value=None,
339+
default_value=credentials,
340+
value=credentials,
379341
required=False,
380342
hidden=True,
381343
)
@@ -386,41 +348,44 @@ def _get_pairing_config_entries(self) -> list[ConfigEntry]:
386348
async def _handle_pairing_action(
387349
self, action: str, values: dict[str, ConfigValueType] | None
388350
) -> None:
389-
"""Handle pairing actions using cliraop.
351+
"""Handle pairing actions using the configured protocol."""
352+
if not self.stream and self.protocol == StreamingProtocol.RAOP:
353+
# ensure we have a stream instance to track pairing state
354+
from .protocols.raop import RaopStream # noqa: PLC0415
390355

391-
TODO: Implement actual cliraop-based pairing.
392-
"""
356+
self.stream = RaopStream(self)
357+
elif not self.stream and self.protocol == StreamingProtocol.AIRPLAY2:
358+
# ensure we have a stream instance to track pairing state
359+
from .protocols.airplay2 import AirPlay2Stream # noqa: PLC0415
360+
361+
self.stream = AirPlay2Stream(self)
393362
if action == CONF_ACTION_START_PAIRING:
394-
# TODO: Start pairing using cliraop
395-
# For now, just set a flag to show the PIN entry
396-
self.mass.config.set_raw_player_config_value(
397-
self.player_id, "_pairing_in_progress", True
398-
)
363+
if self.stream and self.stream.is_pairing:
364+
self.logger.warning("Pairing process already in progress for %s", self.display_name)
365+
return
399366
self.logger.info("Started AirPlay pairing for %s", self.display_name)
367+
if self.stream:
368+
await self.stream.start_pairing()
400369

401370
elif action == CONF_ACTION_FINISH_PAIRING:
402-
# TODO: Finish pairing using cliraop with the provided PIN
403371
if not values:
372+
# guard
404373
return
405374

406375
pin = values.get(CONF_PAIRING_PIN)
407376
if not pin:
408377
self.logger.warning("No PIN provided for pairing")
409378
return
410379

411-
# TODO: Use cliraop to complete pairing with the PIN
412-
# For now, just clear the pairing in progress flag
413-
self.mass.config.set_raw_player_config_value(
414-
self.player_id, "_pairing_in_progress", False
415-
)
380+
if self.stream:
381+
credentials = await self.stream.finish_pairing(pin=str(pin))
382+
else:
383+
return
416384

417-
# TODO: Store the actual credentials obtained from cliraop
418-
# self.mass.config.set_raw_player_config_value(
419-
# self.player_id, CONF_AP_CREDENTIALS, credentials_from_cliraop
420-
# )
385+
values[CONF_AP_CREDENTIALS] = credentials
421386

422387
self.logger.info(
423-
"Finished AirPlay pairing for %s (TODO: implement actual pairing)",
388+
"Finished AirPlay pairing for %s",
424389
self.display_name,
425390
)
426391

@@ -522,7 +487,7 @@ async def play_media(self, media: PlayerMedia) -> None:
522487
if self.stream.prevent_playback:
523488
# player is in prevent playback mode, we need to stop the stream
524489
await self.stop()
525-
else:
490+
elif self.stream.session:
526491
await self.stream.session.replace_stream(audio_source)
527492
return
528493

@@ -564,7 +529,7 @@ async def set_members(
564529
if player_ids_to_remove:
565530
if self.player_id in player_ids_to_remove:
566531
# dissolve the entire sync group
567-
if self.stream and self.stream.running:
532+
if self.stream and self.stream.running and self.stream.session:
568533
# stop the stream session if it is running
569534
await self.stream.session.stop()
570535
self._attr_group_members = []
@@ -598,6 +563,7 @@ async def set_members(
598563
if (
599564
child_player_to_add.stream
600565
and child_player_to_add.stream.running
566+
and child_player_to_add.stream.session
601567
and child_player_to_add.stream.session != stream_session
602568
):
603569
await child_player_to_add.stream.session.remove_client(child_player_to_add)
@@ -663,7 +629,7 @@ async def on_unload(self) -> None:
663629
await super().on_unload()
664630
if self.stream:
665631
# stop the stream session if it is running
666-
if self.stream.running:
632+
if self.stream.running and self.stream.session:
667633
self.mass.create_task(self.stream.session.stop())
668634
self.stream = None
669635

music_assistant/providers/airplay/protocols/_protocol.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,26 @@ class AirPlayProtocol(ABC):
3131
with abstract methods for protocol-specific behavior.
3232
"""
3333

34+
session: AirPlayStreamSession | None = None # reference to the active stream session (if any)
35+
3436
# the pcm audio format used for streaming to this protocol
3537
pcm_format = AudioFormat(
3638
content_type=ContentType.PCM_S16LE, sample_rate=44100, bit_depth=16, channels=2
3739
)
40+
supports_pairing = False # whether this protocol supports pairing
41+
is_pairing: bool = False # whether this protocol instance is in pairing mode
3842

3943
def __init__(
4044
self,
41-
session: AirPlayStreamSession,
4245
player: AirPlayPlayer,
4346
) -> None:
4447
"""Initialize base AirPlay protocol.
4548
4649
Args:
47-
session: The stream session managing this protocol instance
4850
player: The player to stream to
4951
"""
50-
self.session = session
51-
self.prov = session.prov
52-
self.mass = session.prov.mass
52+
self.prov = player.provider
53+
self.mass = player.provider.mass
5354
self.player = player
5455
# Generate unique ID to prevent race conditions with named pipes
5556
self.active_remote_id: str = str(randint(1000, 8000))
@@ -95,6 +96,14 @@ async def start(self, start_ntp: int, skip: int = 0) -> None:
9596
skip: Number of seconds to skip (for late joiners)
9697
"""
9798

99+
async def start_pairing(self) -> None:
100+
"""Start pairing process for this protocol (if supported)."""
101+
raise NotImplementedError("Pairing not implemented for this protocol")
102+
103+
async def finish_pairing(self, pin: str) -> str:
104+
"""Finish pairing process with given PIN (if supported)."""
105+
raise NotImplementedError("Pairing not implemented for this protocol")
106+
98107
async def _open_pipes(self) -> None:
99108
"""Open both named pipes in non-blocking mode for async I/O."""
100109
# Open audio pipe with buffer size optimization

0 commit comments

Comments
 (0)