Fix: ignore invalid mac and ip addresses in protocol linking
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 17 Feb 2026 23:13:44 +0000 (00:13 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 17 Feb 2026 23:13:44 +0000 (00:13 +0100)
music_assistant/controllers/players/README.md
music_assistant/controllers/players/protocol_linking.py
music_assistant/helpers/util.py
music_assistant/providers/airplay/player.py
music_assistant/providers/bluesound/player.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/dlna/player.py
music_assistant/providers/musiccast/player.py
music_assistant/providers/snapcast/player.py
music_assistant/providers/sonos/player.py

index d114034c2ca97f0204bad37400fe7a2d9566a065..dbd817677e754f2e8c9901a606b8423825bdbae3 100644 (file)
@@ -244,7 +244,16 @@ When implementing a new protocol provider:
 
 1. Set `_attr_type = PlayerType.PROTOCOL` for generic devices (non-vendor devices)
 2. Set `_attr_type = PlayerType.PLAYER` for devices with native support (vendor's own devices)
-3. Populate `device_info.identifiers` with MAC, UUID, etc. (see below)
+3. **Populate `device_info.identifiers`** with validated identifiers:
+   ```python
+   from music_assistant.helpers.util import is_valid_mac_address
+
+   # IMPORTANT: Validate MAC addresses before adding them
+   if is_valid_mac_address(mac_address):
+       self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+   self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+   self._attr_device_info.add_identifier(IdentifierType.UUID, uuid)
+   ```
 4. Filter out devices that should only be handled by native providers (e.g., passive satellites)
 5. The Player Controller handles linking automatically
 
@@ -255,12 +264,16 @@ When implementing a native provider (e.g., Sonos, Bluesound) that should link to
 1. Set `_attr_type = PlayerType.PLAYER` (or the property 'type') for all devices
 2. **Populate device identifiers** - This is critical for protocol linking:
    ```python
+   from music_assistant.helpers.util import is_valid_mac_address
+
    self._attr_device_info = DeviceInfo(
        model="Device Model",
        manufacturer="Manufacturer Name",
    )
    # Add identifiers in order of preference (MAC is most reliable)
-   self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
+   # IMPORTANT: Validate MAC addresses before adding them
+   if is_valid_mac_address(mac_address):
+       self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
    self._attr_device_info.add_identifier(IdentifierType.UUID, "device-uuid-here")
    ```
 3. The controller will automatically:
@@ -274,7 +287,12 @@ When implementing a native provider (e.g., Sonos, Bluesound) that should link to
 - `UUID` - Universally unique identifier
 - `player_id` - Fallback when no identifiers available
 
-**Note:** `IP_ADDRESS` is NOT used for matching as it can change with DHCP.
+**Important Notes:**
+- **Always validate MAC addresses** using `is_valid_mac_address()` before adding them
+  - Rejects invalid MACs like `00:00:00:00:00:00` or `ff:ff:ff:ff:ff:ff`
+  - Prevents false matches between unrelated devices
+  - The controller will attempt ARP lookup to resolve real MACs automatically
+- `IP_ADDRESS` is NOT used for matching as it can change with DHCP
 
 ### Testing Protocol Linking
 
index 0ccc5c67d845d32a05901da00af12df3858aa881..a4c8a694b8c3fa5cd1e5dfeca894f597ad0af79d 100644 (file)
@@ -32,7 +32,12 @@ from music_assistant.constants import (
     PROTOCOL_PRIORITY,
     VERBOSE_LOG_LEVEL,
 )
-from music_assistant.helpers.util import is_locally_administered_mac, resolve_real_mac_address
+from music_assistant.helpers.util import (
+    is_locally_administered_mac,
+    is_valid_mac_address,
+    normalize_ip_address,
+    resolve_real_mac_address,
+)
 from music_assistant.models.player import Player
 from music_assistant.providers.universal_player import UniversalPlayer, UniversalPlayerProvider
 
@@ -103,31 +108,67 @@ class ProtocolLinkingMixin:
         This also applies to native players that may report virtual MACs.
         This method tries to resolve the actual hardware MAC via ARP and adds it as an
         additional identifier to enable proper matching between protocols and native players.
+
+        Invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff) are discarded and
+        replaced with the real MAC via ARP lookup.
+
+        IP addresses are normalized (IPv6-mapped IPv4 addresses are converted to IPv4).
         """
         identifiers = player.device_info.identifiers
         reported_mac = identifiers.get(IdentifierType.MAC_ADDRESS)
         ip_address = identifiers.get(IdentifierType.IP_ADDRESS)
 
-        # Skip if no IP available (can't do ARP lookup)
+        # Normalize IP address (handle IPv6-mapped IPv4 like ::ffff:192.168.1.64)
+        if ip_address:
+            normalized_ip = normalize_ip_address(ip_address)
+            if normalized_ip and normalized_ip != ip_address:
+                player.device_info.add_identifier(IdentifierType.IP_ADDRESS, normalized_ip)
+                self.logger.debug(
+                    "Normalized IP address for %s: %s -> %s",
+                    player.state.name,
+                    ip_address,
+                    normalized_ip,
+                )
+                ip_address = normalized_ip
+
+        # Skip MAC enrichment if no IP available (can't do ARP lookup)
         if not ip_address:
             return
 
-        # Skip if MAC already looks like a real one (not locally administered)
-        if reported_mac and not is_locally_administered_mac(reported_mac):
+        # Check if we need to do ARP lookup:
+        # 1. No MAC reported at all
+        # 2. MAC is invalid (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
+        # 3. MAC is locally administered (virtual)
+        should_lookup = (
+            not reported_mac
+            or not is_valid_mac_address(reported_mac)
+            or is_locally_administered_mac(reported_mac)
+        )
+
+        if not should_lookup:
+            # MAC looks valid and is a real hardware MAC
             return
 
         # Try to resolve real MAC via ARP
         real_mac = await resolve_real_mac_address(reported_mac, ip_address)
         if real_mac and real_mac.upper() != (reported_mac or "").upper():
-            # Replace the virtual MAC with the real MAC address
+            # Replace the invalid/virtual MAC with the real MAC address
             # (add_identifier will store multiple values if the implementation supports it)
             player.device_info.add_identifier(IdentifierType.MAC_ADDRESS, real_mac)
-            self.logger.debug(
-                "Resolved real MAC for %s: %s -> %s",
-                player.state.name,
-                reported_mac,
-                real_mac,
-            )
+            if reported_mac and not is_valid_mac_address(reported_mac):
+                self.logger.debug(
+                    "Replaced invalid MAC for %s: %s -> %s",
+                    player.state.name,
+                    reported_mac,
+                    real_mac,
+                )
+            else:
+                self.logger.debug(
+                    "Resolved real MAC for %s: %s -> %s",
+                    player.state.name,
+                    reported_mac or "none",
+                    real_mac,
+                )
 
     def _evaluate_protocol_links(self, player: Player) -> None:
         """
@@ -732,6 +773,9 @@ class ProtocolLinkingMixin:
         IP address is used as a fallback for protocol players only, because some
         devices report different virtual MAC addresses per protocol (e.g., DLNA vs
         AirPlay vs Chromecast may all have different MACs for the same device).
+
+        Invalid identifiers (e.g., 00:00:00:00:00:00 MAC addresses) are filtered out
+        to prevent false matches between unrelated devices.
         """
         identifiers_a = player_a.device_info.identifiers
         identifiers_b = player_b.device_info.identifiers
@@ -749,6 +793,19 @@ class ProtocolLinkingMixin:
             if not val_a or not val_b:
                 continue
 
+            # Filter out invalid MAC addresses (00:00:00:00:00:00, ff:ff:ff:ff:ff:ff)
+            if conn_type == IdentifierType.MAC_ADDRESS:
+                if not is_valid_mac_address(val_a) or not is_valid_mac_address(val_b):
+                    self.logger.log(
+                        VERBOSE_LOG_LEVEL,
+                        "Skipping invalid MAC address for matching: %s=%s, %s=%s",
+                        player_a.display_name,
+                        val_a,
+                        player_b.display_name,
+                        val_b,
+                    )
+                    continue
+
             # Normalize values for comparison
             val_a_norm = val_a.lower().replace(":", "").replace("-", "")
             val_b_norm = val_b.lower().replace(":", "").replace("-", "")
@@ -772,7 +829,12 @@ class ProtocolLinkingMixin:
         if self._can_use_ip_matching(player_a, player_b):
             ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
             ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
-            if ip_a and ip_b and ip_a == ip_b:
+
+            # Normalize IP addresses (handle IPv6-mapped IPv4 like ::ffff:192.168.1.64)
+            ip_a_normalized = normalize_ip_address(ip_a)
+            ip_b_normalized = normalize_ip_address(ip_b)
+
+            if ip_a_normalized and ip_b_normalized and ip_a_normalized == ip_b_normalized:
                 return True
 
         return False
index b2977e7f2d6d816e50053f17926cc12f415064b9..3bd9c3e24aa54f3c56997e859e1f909f35fac61a 100644 (file)
@@ -770,6 +770,60 @@ def is_locally_administered_mac(mac_address: str) -> bool:
         return False
 
 
+def is_valid_mac_address(mac_address: str | None) -> bool:
+    """
+    Check if a MAC address is valid and usable for device identification.
+
+    Invalid MAC addresses include:
+    - None or empty strings
+    - Null MAC: 00:00:00:00:00:00
+    - Broadcast MAC: ff:ff:ff:ff:ff:ff
+    - Any MAC that doesn't follow the expected pattern
+
+    :param mac_address: MAC address to validate.
+    :return: True if valid and usable, False otherwise.
+    """
+    if not mac_address:
+        return False
+
+    # Normalize MAC address (remove separators and convert to lowercase)
+    normalized = mac_address.lower().replace(":", "").replace("-", "")
+
+    # Check for invalid/reserved MAC addresses
+    if normalized in ("000000000000", "ffffffffffff"):
+        return False
+
+    # Check length and hex validity
+    if len(normalized) != 12:
+        return False
+
+    try:
+        int(normalized, 16)
+        return True
+    except ValueError:
+        return False
+
+
+def normalize_ip_address(ip_address: str | None) -> str | None:
+    """
+    Normalize IP address for comparison.
+
+    Handles IPv6-mapped IPv4 addresses (e.g., ::ffff:192.168.1.64 -> 192.168.1.64).
+
+    :param ip_address: IP address to normalize.
+    :return: Normalized IP address or None if invalid.
+    """
+    if not ip_address:
+        return None
+
+    # Handle IPv6-mapped IPv4 addresses
+    if ip_address.startswith("::ffff:"):
+        # Extract the IPv4 part
+        return ip_address[7:]
+
+    return ip_address
+
+
 async def resolve_real_mac_address(reported_mac: str | None, ip_address: str | None) -> str | None:
     """
     Resolve the real MAC address for a device.
@@ -788,7 +842,7 @@ async def resolve_real_mac_address(reported_mac: str | None, ip_address: str | N
     # If no MAC reported or it's a locally administered one, try ARP lookup
     if not reported_mac or is_locally_administered_mac(reported_mac):
         real_mac = await get_mac_address(ip_address)
-        if real_mac and real_mac.lower() not in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"):
+        if real_mac and is_valid_mac_address(real_mac):
             return real_mac.upper()
 
     return None
index b9d4445d437ca6e0f84fe89fb59763c038604b4e..44061715ef5c97cad9ca0c3d39c98373d5a5bd46 100644 (file)
@@ -16,6 +16,7 @@ from music_assistant_models.enums import (
 )
 
 from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
 
 from .constants import (
@@ -91,7 +92,9 @@ class AirPlayPlayer(Player):
             model=model,
             manufacturer=manufacturer,
         )
-        self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+        if is_valid_mac_address(mac_address):
+            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
         self._attr_volume_level = initial_volume
         self._attr_can_group_with = {provider.instance_id}
index 1b9a4a006520f3a587b799c52771da2f068960cc..a962547888d9032fc1eed45fe0b41771964962a4 100644 (file)
@@ -19,6 +19,7 @@ from music_assistant.constants import (
     CONF_ENTRY_ICY_METADATA_DEFAULT_FULL,
     create_sample_rates_config_entry,
 )
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
 from music_assistant.providers.bluesound.const import (
     IDLE_POLL_INTERVAL,
@@ -69,8 +70,10 @@ class BluesoundPlayer(Player):
             manufacturer="BluOS",
         )
         self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
         if mac_address := discovery_info.get("mac"):
-            self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+            if is_valid_mac_address(mac_address):
+                self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_available = True
         self._attr_source_list = []
         self._attr_needs_poll = True
index fd8d3e974c530fc3f549fd23e2026aa2e8d03ac1..b650d347dbbdc058e97b9a3dbe99f4fb81cd97f9 100644 (file)
@@ -30,6 +30,7 @@ from pychromecast.controllers.multizone import MultizoneController
 from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
 
 from music_assistant.constants import MASS_LOGO_ONLINE, VERBOSE_LOG_LEVEL
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
 
 from .constants import (
@@ -117,9 +118,11 @@ class ChromecastPlayer(Player):
             manufacturer=self.cast_info.manufacturer or "",
         )
         self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
-        self._attr_device_info.add_identifier(
-            IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
-        )
+        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+        if is_valid_mac_address(self.cast_info.mac_address):
+            self._attr_device_info.add_identifier(
+                IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
+            )
         self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
         assert provider.mz_mgr is not None  # for type checking
         status_listener = CastStatusListener(self, provider.mz_mgr)
@@ -799,7 +802,8 @@ class ChromecastPlayer(Player):
             )
             self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
             self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
-            if self.cast_info.mac_address:
+            # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+            if is_valid_mac_address(self.cast_info.mac_address):
                 self._attr_device_info.add_identifier(
                     IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
                 )
index 639c2ab6c153a75c2da82e9ed1e130a265cb66c8..107426583dd36ca72ac36b5ea08052c886eb8e44 100644 (file)
@@ -19,6 +19,7 @@ from music_assistant_models.player import PlayerMedia
 
 from music_assistant.constants import VERBOSE_LOG_LEVEL
 from music_assistant.helpers.upnp import create_didl_metadata
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import DeviceInfo, Player
 
 from .constants import PLAYER_CONFIG_ENTRIES
@@ -148,7 +149,8 @@ class DLNAPlayer(Player):
                 # Many UPnP devices embed MAC in the last 12 chars of UUID
                 # e.g., uuid:4d691234-444c-164e-1234-001f33eaacf1 -> 00:1f:33:ea:ac:f1
                 mac_address = self._extract_mac_from_uuid(uuid_value)
-                if mac_address:
+                # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+                if mac_address and is_valid_mac_address(mac_address):
                     self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
                 # Try to extract just the IP from the URL for matching
                 ip_address = self.device.device.presentation_url or self.description_url
index 6d558d17b50c2162a6c59fbeb049c2816a00d728..8bb9c576c1c62eb725c7e67b2c731d1e3b4ada26 100644 (file)
@@ -36,6 +36,7 @@ from music_assistant_models.player import (
 from music_assistant_models.unique_list import UniqueList
 from propcache import under_cached_property as cached_property
 
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import Player
 from music_assistant.providers.musiccast.avt_helpers import (
     avt_get_media_info,
@@ -139,7 +140,9 @@ class MusicCastPlayer(Player):
             # device_id is the MAC address (12 hex chars), format as XX:XX:XX:XX:XX:XX
             if len(device_id) == 12:
                 mac = ":".join(device_id[i : i + 2].upper() for i in range(0, 12, 2))
-                self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
+                # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+                if is_valid_mac_address(mac):
+                    self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
 
         # polling
         self._attr_needs_poll = True
index a84b925dc5f8e8e576f6d5c47e0159cf8801bccd..a92f7f14f9173175c1e4fb961f64860a5e42f45c 100644 (file)
@@ -18,6 +18,7 @@ from music_assistant_models.player import DeviceInfo, PlayerMedia
 from propcache import under_cached_property as cached_property
 
 from music_assistant.constants import ATTR_ANNOUNCEMENT_IN_PROGRESS, CONF_ENTRY_HTTP_PROFILE_HIDDEN
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import Player
 from music_assistant.providers.snapcast.constants import CONF_ENTRY_SAMPLE_RATES_SNAPCAST
 from music_assistant.providers.snapcast.ma_stream import SnapcastMAStream
@@ -178,7 +179,8 @@ class SnapCastPlayer(Player):
         )
         if ip and (host := self.snap_client._client.get("host")):
             self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, host.get("ip"))
-        if mac:
+        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+        if mac and is_valid_mac_address(mac):
             self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
         self._attr_supported_features = {
             PlayerFeature.PLAY_MEDIA,
index d3ece7daaee61150357e9b9b17abd69fb54e4d19..074890b1dddd095f97b4354e725bed95d10925a8 100644 (file)
@@ -36,6 +36,7 @@ from music_assistant.constants import (
     create_sample_rates_config_entry,
 )
 from music_assistant.helpers.tags import async_parse_tags
+from music_assistant.helpers.util import is_valid_mac_address
 from music_assistant.models.player import Player
 from music_assistant.providers.sonos.const import (
     PLAYBACK_STATE_MAP,
@@ -140,7 +141,8 @@ class SonosPlayer(Player):
         # Extract MAC address from Sonos player_id (RINCON_XXXXXXXXXXXX01400)
         # The middle part contains the MAC address (last 6 bytes in hex)
         mac_address = self._extract_mac_from_player_id()
-        if mac_address:
+        # Only add MAC address if it's valid (not 00:00:00:00:00:00)
+        if mac_address and is_valid_mac_address(mac_address):
             self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
 
         if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]: