From 5ae5d952aba1ac12937f22f86899ff8627756bd3 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 18 Feb 2026 00:13:44 +0100 Subject: [PATCH] Fix: ignore invalid mac and ip addresses in protocol linking --- music_assistant/controllers/players/README.md | 24 +++++- .../controllers/players/protocol_linking.py | 86 ++++++++++++++++--- music_assistant/helpers/util.py | 56 +++++++++++- music_assistant/providers/airplay/player.py | 5 +- music_assistant/providers/bluesound/player.py | 5 +- .../providers/chromecast/player.py | 12 ++- music_assistant/providers/dlna/player.py | 4 +- music_assistant/providers/musiccast/player.py | 5 +- music_assistant/providers/snapcast/player.py | 4 +- music_assistant/providers/sonos/player.py | 4 +- 10 files changed, 179 insertions(+), 26 deletions(-) diff --git a/music_assistant/controllers/players/README.md b/music_assistant/controllers/players/README.md index d114034c..dbd81767 100644 --- a/music_assistant/controllers/players/README.md +++ b/music_assistant/controllers/players/README.md @@ -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 diff --git a/music_assistant/controllers/players/protocol_linking.py b/music_assistant/controllers/players/protocol_linking.py index 0ccc5c67..a4c8a694 100644 --- a/music_assistant/controllers/players/protocol_linking.py +++ b/music_assistant/controllers/players/protocol_linking.py @@ -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 diff --git a/music_assistant/helpers/util.py b/music_assistant/helpers/util.py index b2977e7f..3bd9c3e2 100644 --- a/music_assistant/helpers/util.py +++ b/music_assistant/helpers/util.py @@ -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 diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index b9d4445d..44061715 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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} diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index 1b9a4a00..a9625478 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -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 diff --git a/music_assistant/providers/chromecast/player.py b/music_assistant/providers/chromecast/player.py index fd8d3e97..b650d347 100644 --- a/music_assistant/providers/chromecast/player.py +++ b/music_assistant/providers/chromecast/player.py @@ -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 ) diff --git a/music_assistant/providers/dlna/player.py b/music_assistant/providers/dlna/player.py index 639c2ab6..10742658 100644 --- a/music_assistant/providers/dlna/player.py +++ b/music_assistant/providers/dlna/player.py @@ -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 diff --git a/music_assistant/providers/musiccast/player.py b/music_assistant/providers/musiccast/player.py index 6d558d17..8bb9c576 100644 --- a/music_assistant/providers/musiccast/player.py +++ b/music_assistant/providers/musiccast/player.py @@ -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 diff --git a/music_assistant/providers/snapcast/player.py b/music_assistant/providers/snapcast/player.py index a84b925d..a92f7f14 100644 --- a/music_assistant/providers/snapcast/player.py +++ b/music_assistant/providers/snapcast/player.py @@ -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, diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index d3ece7da..074890b1 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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"]: -- 2.34.1