/
/
/
1"""
2Hue Lights Sync provider for Music Assistant.
3
4Discovers entertainment areas on a paired Hue bridge and creates
5virtual Sendspin players for each. When music plays to a virtual player,
6the lights in that entertainment area react to the music.
7"""
8
9from __future__ import annotations
10
11import logging
12from typing import TYPE_CHECKING
13
14from hue_entertainment import HueEntertainmentAPI
15from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
16from music_assistant_models.enums import ConfigEntryType
17from zeroconf import ServiceStateChange
18
19from music_assistant.models.plugin import PluginProvider
20
21from .bridge import HueEntertainmentBridgeManager
22from .constants import (
23 COLOR_MODES,
24 CONF_BRIDGE_HOST,
25 CONF_BRIDGE_ID,
26 CONF_BRIGHTNESS,
27 CONF_COLOR_MODE,
28 CONF_HUE_LATENCY_MS,
29 CONF_USERNAME,
30 DEFAULT_COLOR_MODE,
31 DEFAULT_HUE_LATENCY_MS,
32)
33
34if TYPE_CHECKING:
35 from music_assistant_models.config_entries import ProviderConfig
36 from music_assistant_models.enums import ProviderFeature
37 from music_assistant_models.provider import ProviderManifest
38 from zeroconf.asyncio import AsyncServiceInfo
39
40 from music_assistant.mass import MusicAssistant
41
42LOGGER = logging.getLogger(__name__)
43
44
45class HueEntertainmentProvider(PluginProvider):
46 """Provider that syncs Hue lights to music via Sendspin."""
47
48 def __init__(
49 self,
50 mass: MusicAssistant,
51 manifest: ProviderManifest,
52 config: ProviderConfig,
53 supported_features: set[ProviderFeature],
54 ) -> None:
55 """Initialize the provider."""
56 super().__init__(mass, manifest, config, supported_features)
57 self._hue_api: HueEntertainmentAPI | None = None
58 self._bridge_manager: HueEntertainmentBridgeManager | None = None
59
60 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
61 """
62 Return the (options) config entries for the Hue Entertainment provider.
63
64 Bridge pairing runs in the interactive setup flow (see ``setup_flow.py``); only the
65 playback/visualization settings are configured here.
66 """
67 return (
68 ConfigEntry(
69 key=CONF_BRIGHTNESS,
70 type=ConfigEntryType.INTEGER,
71 default_value=100,
72 range=(0, 100),
73 category="settings",
74 ),
75 ConfigEntry(
76 key=CONF_COLOR_MODE,
77 type=ConfigEntryType.STRING,
78 default_value=DEFAULT_COLOR_MODE,
79 options=[ConfigValueOption(mode, title=mode.capitalize()) for mode in COLOR_MODES],
80 category="settings",
81 ),
82 ConfigEntry(
83 key=CONF_HUE_LATENCY_MS,
84 type=ConfigEntryType.INTEGER,
85 default_value=DEFAULT_HUE_LATENCY_MS,
86 range=(0, 3000),
87 immediate_apply=True,
88 category="settings",
89 ),
90 )
91
92 @property
93 def hue_api(self) -> HueEntertainmentAPI | None:
94 """Return the Hue API client."""
95 return self._hue_api
96
97 async def loaded_in_mass(self) -> None:
98 """Initialize Hue bridge connection and set up entertainment area bridges."""
99 # Migrate orphaned color_mode values from older versions to the default
100 # so the settings dropdown shows a valid option.
101 stored_mode = self.config.get_value(CONF_COLOR_MODE)
102 if stored_mode is not None and str(stored_mode) not in COLOR_MODES:
103 self._update_config_value(CONF_COLOR_MODE, DEFAULT_COLOR_MODE)
104
105 host = self.get_setup_value(CONF_BRIDGE_HOST)
106 username = self.get_setup_value(CONF_USERNAME)
107
108 if not host or not username:
109 self.logger.warning("Hue bridge not configured, provider inactive")
110 self.available = False
111 return
112
113 self._hue_api = HueEntertainmentAPI(str(host), str(username))
114 self._bridge_manager = HueEntertainmentBridgeManager(self)
115
116 # Fetch entertainment areas and set up bridges
117 try:
118 areas = await self._hue_api.get_entertainment_areas()
119 if not areas:
120 self.logger.warning("No entertainment areas found on Hue bridge at %s", host)
121 else:
122 self.logger.info(
123 "Found %d entertainment area(s) on Hue bridge: %s",
124 len(areas),
125 ", ".join(a.name for a in areas),
126 )
127 await self._bridge_manager.setup_bridges(areas)
128 self.available = True
129 except Exception as err:
130 self.logger.error("Failed to initialize Hue bridge at %s: %s", host, err)
131 self.available = False
132
133 async def unload(self, is_removed: bool = False) -> None:
134 """Handle unload/close of the provider."""
135 if self._bridge_manager:
136 await self._bridge_manager.stop_all()
137 self._bridge_manager = None
138 if self._hue_api:
139 await self._hue_api.close()
140 self._hue_api = None
141
142 async def on_mdns_service_state_change(
143 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
144 ) -> None:
145 """
146 Handle mDNS service discovery for Hue bridges.
147
148 Updates the bridge IP address if it changes (e.g. DHCP renewal).
149 """
150 if info is None:
151 return
152
153 # Extract the bridge ID from the mDNS service name
154 raw_bridge_id = info.properties.get(b"bridgeid", b"")
155 bridge_id = raw_bridge_id.decode("utf-8", errors="ignore") if raw_bridge_id else ""
156 if not bridge_id:
157 return
158
159 configured_bridge_id = self.get_setup_value(CONF_BRIDGE_ID) or ""
160
161 if state_change == ServiceStateChange.Removed:
162 if bridge_id == configured_bridge_id:
163 self.logger.info("Hue bridge %s removed from network", bridge_id)
164 self.available = False
165 return
166
167 # Extract IP address from service info
168 addresses = info.parsed_addresses()
169 if not addresses:
170 return
171 new_host = addresses[0]
172
173 if state_change == ServiceStateChange.Added:
174 # If we don't have a bridge ID configured yet, and no host is set,
175 # this is likely the initial discovery during setup
176 if not configured_bridge_id:
177 self.logger.debug(
178 "Discovered Hue bridge %s at %s (not yet configured)", bridge_id, new_host
179 )
180 return
181
182 if bridge_id != configured_bridge_id:
183 return
184
185 # Update the host if it changed
186 current_host = self.get_setup_value(CONF_BRIDGE_HOST) or ""
187 if new_host != current_host:
188 self.logger.info(
189 "Hue bridge %s IP changed from %s to %s",
190 bridge_id,
191 current_host,
192 new_host,
193 )
194 if self._hue_api:
195 self._hue_api.host = new_host
196 # Persist the new IP
197 self._update_setup_data(CONF_BRIDGE_HOST, new_host)
198
199 if not self.available:
200 self.available = True
201 # Re-initialize bridges if we were previously unavailable
202 if self._hue_api and self._bridge_manager:
203 try:
204 areas = await self._hue_api.get_entertainment_areas()
205 await self._bridge_manager.setup_bridges(areas)
206 except Exception as err:
207 self.logger.warning("Failed to reinitialize bridges: %s", err)
208
209 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
210 """
211 Handle config changes.
212
213 Settings like brightness/color_mode can be updated
214 without a full provider reload.
215 """
216 settings_keys = {CONF_BRIGHTNESS, CONF_COLOR_MODE, CONF_HUE_LATENCY_MS}
217 if changed_keys & settings_keys and self._bridge_manager:
218 self._bridge_manager.update_settings(
219 color_mode=str(config.get_value(CONF_COLOR_MODE) or "smooth"),
220 brightness=int(float(str(config.get_value(CONF_BRIGHTNESS) or 100))),
221 hue_latency_ms=int(
222 float(str(config.get_value(CONF_HUE_LATENCY_MS) or DEFAULT_HUE_LATENCY_MS))
223 ),
224 )
225 self.config = config
226 return
227
228 # For other changes (host, credentials), do a full reload
229 await super().update_config(config, changed_keys)
230