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
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:
- `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
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
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:
"""
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
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("-", "")
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
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.
# 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
)
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 (
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}
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,
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
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 (
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)
)
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
)
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
# 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
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,
# 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
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
)
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,
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,
# 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"]: