is_locally_administered_mac,
is_valid_mac_address,
normalize_ip_address,
+ normalize_mac_for_matching,
resolve_real_mac_address,
)
from music_assistant.models.player import Player
continue
# Normalize values for comparison
- val_a_norm = val_a.lower().replace(":", "").replace("-", "")
- val_b_norm = val_b.lower().replace(":", "").replace("-", "")
+ if conn_type == IdentifierType.MAC_ADDRESS:
+ # Use MAC normalization that handles locally-administered bit differences
+ # Some protocols (like AirPlay) report a locally-administered MAC variant
+ # where bit 1 of the first octet is set (e.g., 54:78:... vs 56:78:...)
+ val_a_norm = normalize_mac_for_matching(val_a)
+ val_b_norm = normalize_mac_for_matching(val_b)
+ else:
+ val_a_norm = val_a.lower().replace(":", "").replace("-", "")
+ val_b_norm = val_b.lower().replace(":", "").replace("-", "")
# Direct match
if val_a_norm == val_b_norm:
return False
+def normalize_mac_for_matching(mac_address: str) -> str:
+ """
+ Normalize a MAC address for device matching by masking out the locally-administered bit.
+
+ Some protocols (like AirPlay) report a locally-administered MAC address variant where
+ bit 1 of the first octet is set. For example:
+ - Real hardware MAC: 54:78:C9:E6:0D:A0 (first byte 0x54 = 01010100)
+ - AirPlay reports: 56:78:C9:E6:0D:A0 (first byte 0x56 = 01010110)
+
+ These represent the same device but differ only in the locally-administered bit.
+ This function normalizes the MAC by clearing bit 1 of the first octet, allowing
+ both variants to match the same device.
+
+ :param mac_address: MAC address in any common format (with :, -, or no separator).
+ :return: Normalized MAC address in lowercase without separators, with the
+ locally-administered bit cleared.
+ """
+ # Normalize MAC address (remove separators, lowercase)
+ mac_clean = mac_address.lower().replace(":", "").replace("-", "")
+ if len(mac_clean) != 12:
+ # Invalid MAC length, return as-is
+ return mac_clean
+
+ try:
+ # Parse first octet and clear bit 1 (the locally-administered bit)
+ first_octet = int(mac_clean[:2], 16)
+ first_octet_normalized = first_octet & ~0x02 # Clear bit 1
+ # Reconstruct the MAC with the normalized first octet
+ return f"{first_octet_normalized:02x}{mac_clean[2:]}"
+ except ValueError:
+ # Invalid hex, return as-is
+ return mac_clean
+
+
def is_valid_mac_address(mac_address: str | None) -> bool:
"""
Check if a MAC address is valid and usable for device identification.
from music_assistant_models.enums import IdentifierType, PlayerType
from music_assistant.constants import CONF_PLAYERS
+from music_assistant.helpers.util import normalize_mac_for_matching
from music_assistant.models.player import DeviceInfo
from music_assistant.models.player_provider import PlayerProvider
for player in protocol_players:
identifiers = player.device_info.identifiers
# Prefer MAC address (most reliable)
+ # Use normalize_mac_for_matching to handle locally-administered MAC variants
+ # Some protocols (like AirPlay) report a variant where bit 1 of the first octet
+ # is set (e.g., 54:78:... vs 56:78:...), but they represent the same device
if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
- return mac.replace(":", "").replace("-", "").lower()
+ return normalize_mac_for_matching(mac)
# Fall back to UUID (reliable for DLNA, Chromecast)
if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
# Normalize UUID: remove special characters, lowercase