Fix for protocol matching with locally administered mac
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 19 Feb 2026 10:03:46 +0000 (11:03 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 19 Feb 2026 10:03:46 +0000 (11:03 +0100)
music_assistant/controllers/players/protocol_linking.py
music_assistant/helpers/util.py
music_assistant/providers/universal_player/provider.py

index 5b0c1ed376c22443001e2e2012afddedb6d90434..b8758575e38c967af7ae9415d4a6017f6887ac2d 100644 (file)
@@ -36,6 +36,7 @@ from music_assistant.helpers.util import (
     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
@@ -812,8 +813,15 @@ class ProtocolLinkingMixin:
                     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:
index ea5b7423925564bd86323600d194c5a70d07d5f9..2b8f5424b762258f9366c072f25005f27bb03ff1 100644 (file)
@@ -770,6 +770,40 @@ def is_locally_administered_mac(mac_address: str) -> bool:
         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.
index 618b76b1d40d326fba289922be35f8ea9813d7a3..de1200108be61927bd146bb9083ed1796bec322c 100644 (file)
@@ -16,6 +16,7 @@ from typing import TYPE_CHECKING
 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
 
@@ -367,8 +368,11 @@ class UniversalPlayerProvider(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