/
/
1"""WiiM Player Provider implementation."""
2
3from __future__ import annotations
4
5import logging
6from typing import TYPE_CHECKING, cast
7
8from music_assistant_models.enums import IdentifierType
9from wiim import WiimController
10from wiim.discovery import async_create_wiim_device, verify_wiim_device
11from wiim.exceptions import WiimDeviceException, WiimRequestException
12from zeroconf import ServiceStateChange
13
14from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
15from music_assistant.helpers.util import (
16 get_port_from_zeroconf,
17 get_primary_ip_address_from_zeroconf,
18)
19from music_assistant.models.player_provider import PlayerProvider
20
21from .constants import PLAYER_ID_PREFIX
22from .player import WiimPlayer
23
24if TYPE_CHECKING:
25 from music_assistant_models.config_entries import ConfigEntry
26 from zeroconf.asyncio import AsyncServiceInfo
27
28
29class WiimProvider(PlayerProvider):
30 """
31 WiiM player provider.
32
33 This provides a WiiM player implementation for Music Assistant.
34 """
35
36 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
37 """Return Config entries to setup this provider."""
38 return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,)
39
40 async def handle_async_init(self) -> None:
41 """Handle async initialization of the provider."""
42 # the sdk logs routine keep-alive chatter at INFO
43 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
44 logging.getLogger("wiim").setLevel(logging.DEBUG)
45 else:
46 logging.getLogger("wiim").setLevel(max(self.logger.level + 10, logging.WARNING))
47
48 self.wiim_controller = WiimController(self.mass.http_session_no_ssl)
49
50 async def loaded_in_mass(self) -> None:
51 """Call after the provider has been loaded."""
52 self.logger.info("WiimProvider loaded")
53
54 manual_ip_config: list[str] = cast(
55 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
56 )
57
58 for ip_address in manual_ip_config:
59 stripped_ip_address = ip_address.strip()
60 potential_locations = (
61 f"http://{stripped_ip_address}:49152/description.xml",
62 f"http://{stripped_ip_address}/description.xml",
63 )
64
65 matched_location = None
66 upnp_device = None
67 for location in potential_locations:
68 upnp_device = await verify_wiim_device(location, self.mass.http_session_no_ssl)
69 if upnp_device:
70 matched_location = location
71 break
72
73 if not upnp_device or not matched_location:
74 continue
75
76 player_id = f"{PLAYER_ID_PREFIX}{upnp_device.udn}"
77 await self.try_add_player(player_id, stripped_ip_address, "Unknown", matched_location)
78
79 async def on_mdns_service_state_change(
80 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
81 ) -> None:
82 """Handle MDNS service state callback."""
83 if not info:
84 self.logger.debug("mDNS callback: no info, returning")
85 return
86
87 if state_change == ServiceStateChange.Removed:
88 return # ignore, rely on SDK availability polling
89
90 cur_address = get_primary_ip_address_from_zeroconf(info)
91 self.logger.debug("mDNS callback: cur_address=%s", cur_address)
92 if cur_address is None:
93 return
94
95 # Try to get player_id from mDNS properties first (avoids network call)
96 udn = info.decoded_properties.get("uuid") if info.decoded_properties else None
97 wiim_player_id = f"{PLAYER_ID_PREFIX}{udn}" if udn else None
98
99 # Check for existing player before hitting the network
100 if wiim_player_id and (mass_player := self.mass.players.get_player(wiim_player_id)):
101 assert isinstance(mass_player, WiimPlayer), (
102 "Player ID already exists but is not a WiimPlayer"
103 )
104 if cur_address and cur_address != mass_player.device_info.ip_address:
105 self.logger.debug(
106 "Address updated to %s for player %s", cur_address, mass_player.display_name
107 )
108 mass_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
109 self.mass.players.trigger_player_update(wiim_player_id)
110 return
111
112 # New device -- verify it's a WiiM and set up
113 self.logger.debug("mDNS callback: new device, verifying at %s", cur_address)
114 potential_locations = (
115 f"http://{cur_address}:{get_port_from_zeroconf(info)}/description.xml",
116 f"http://{cur_address}/description.xml",
117 f"http://{cur_address}:49152/description.xml",
118 )
119
120 matched_location = None
121 upnp_device = None
122 for location in potential_locations:
123 upnp_device = await verify_wiim_device(location, self.mass.http_session_no_ssl)
124 if upnp_device:
125 matched_location = location
126 break
127
128 if not upnp_device or not matched_location:
129 self.logger.debug("mDNS callback: verify_wiim_device failed for %s", cur_address)
130 return
131
132 player_id = f"{PLAYER_ID_PREFIX}{upnp_device.udn}"
133
134 # Extract MAC address from mDNS properties for protocol linking
135 mac_address: str | None = None
136 if info.decoded_properties:
137 mac_address = info.decoded_properties.get("MAC")
138
139 self.logger.debug("Discovered device %s on %s (MAC: %s)", name, cur_address, mac_address)
140 task_id = f"setup_wiim_{player_id}"
141 self.mass.call_later(
142 5,
143 self.try_add_player,
144 player_id,
145 cur_address,
146 name,
147 matched_location,
148 mac_address,
149 task_id=task_id,
150 )
151
152 async def try_add_player(
153 self,
154 player_id: str,
155 ip_address: str,
156 name: str,
157 upnp_location: str,
158 mac_address: str | None = None,
159 ) -> None:
160 """Try to add a WiiM device as a player."""
161 try:
162 wiim_dev = await async_create_wiim_device(
163 upnp_location,
164 self.mass.http_session_no_ssl,
165 host=ip_address,
166 local_host=await self.mass.streams.get_source_ip(ip_address),
167 polling_interval=60,
168 )
169 except (WiimRequestException, WiimDeviceException) as err:
170 self.logger.warning("Failed to initialize WiiM device at %s: %s", ip_address, err)
171 return
172 except Exception:
173 self.logger.exception("Unexpected error initializing WiiM device at %s", ip_address)
174 return
175
176 self.logger.debug("try_add_player: device created: %s (%s)", wiim_dev.name, wiim_dev.udn)
177 await self.wiim_controller.add_device(wiim_dev)
178 try:
179 player = WiimPlayer(
180 provider=self,
181 player_id=player_id,
182 device=wiim_dev,
183 mac_address=mac_address,
184 )
185 await player.setup()
186 await self.mass.players.register_or_update(player)
187 self.logger.info("WiiM player registered: %s (%s)", wiim_dev.name, player_id)
188 except Exception:
189 self.logger.exception("Failed to register WiiM player %s", wiim_dev.name)
190 await self.wiim_controller.remove_device(wiim_dev.udn)
191 await wiim_dev.disconnect()
192