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