202202# Keep a short domain version for the context instances (which can only be 36 chars)
203203_DOMAIN_SHORT = "al"
204204
205+ ServiceData = dict [str , Any ]
206+
205207
206208def _int_to_base36 (num : int ) -> str :
207209 """
@@ -280,25 +282,39 @@ def is_our_context(context: Context | None) -> bool:
280282 return f":{ _DOMAIN_SHORT } :" in context .id
281283
282284
283- def _split_service_data (service_data , adapt_brightness , adapt_color ):
284- """Split service_data into two dictionaries (for color and brightness)."""
285- transition = service_data .get (ATTR_TRANSITION )
286- if transition is not None :
287- # Split the transition over both commands
288- service_data [ATTR_TRANSITION ] /= 2
289- service_datas = []
290- if adapt_color :
291- service_data_color = service_data .copy ()
292- service_data_color .pop (ATTR_BRIGHTNESS , None )
293- service_datas .append (service_data_color )
294- if adapt_brightness :
295- service_data_brightness = service_data .copy ()
296- service_data_brightness .pop (ATTR_RGB_COLOR , None )
297- service_data_brightness .pop (ATTR_COLOR_TEMP_KELVIN , None )
298- service_datas .append (service_data_brightness )
299-
300- if not service_datas : # neither adapt_brightness nor adapt_color
285+ def _prepare_service_calls (service_data : ServiceData , split = False ) -> list [ServiceData ]:
286+ """Prepares the service data for service calls.
287+
288+ Processes the service_data according to the config flags, optionally splitting
289+ it into multiple data items for the separate adaptation of different attributes.
290+ Returns a list of service_datas that indicates the required service calls. If
291+ no splitting is necessary, the output is a list with a single item.
292+ """
293+ if not split :
301294 return [service_data ]
295+
296+ common_attrs = {ATTR_ENTITY_ID }
297+ common_data = {k : service_data [k ] for k in common_attrs if k in service_data }
298+
299+ attributes_split_sequence = [BRIGHTNESS_ATTRS , COLOR_ATTRS ]
300+ service_datas = []
301+
302+ for attributes in attributes_split_sequence :
303+ split_data = {
304+ attribute : service_data [attribute ]
305+ for attribute in attributes
306+ if service_data .get (attribute )
307+ }
308+ if split_data :
309+ service_datas .append (common_data | split_data )
310+
311+ # Distribute the transition duration across all service calls
312+ if service_datas and (transition := service_data .get (ATTR_TRANSITION )) is not None :
313+ transition = service_data [ATTR_TRANSITION ] / len (service_datas )
314+
315+ for service_data in service_datas :
316+ service_data [ATTR_TRANSITION ] = transition
317+
302318 return service_datas
303319
304320
@@ -1185,7 +1201,23 @@ async def _adapt_light( # noqa: C901
11851201 else :
11861202 self .turn_on_off_listener .last_service_data [light ] = service_data
11871203
1188- async def turn_on (service_data ):
1204+ service_datas = _prepare_service_calls (
1205+ service_data , self ._separate_turn_on_commands
1206+ )
1207+ await self ._make_cancellable_adaptation_calls (service_datas , context , light )
1208+
1209+ async def _make_adaptation_calls (
1210+ self , service_datas : list [ServiceData ], context : Context
1211+ ):
1212+ """Executes a sequence of adaptation service calls for the given service datas."""
1213+ for i , service_data in enumerate (service_datas ):
1214+ is_first_call = i == 0
1215+
1216+ # Sleep _between_ multiple service calls, but not before the first or a single one.
1217+ if not is_first_call :
1218+ await asyncio .sleep (service_data .get (ATTR_TRANSITION , 0 ))
1219+ await asyncio .sleep (self ._send_split_delay / 1000.0 )
1220+
11891221 _LOGGER .debug (
11901222 "%s: Scheduling 'light.turn_on' with the following 'service_data': %s"
11911223 " with context.id='%s'" ,
@@ -1200,30 +1232,27 @@ async def turn_on(service_data):
12001232 context = context ,
12011233 )
12021234
1203- async def turn_on_split ():
1204- # Could be a list of length 1 or 2
1205- service_datas = _split_service_data (
1206- service_data , adapt_brightness , adapt_color
1207- )
1208- await turn_on (service_datas [0 ])
1209- if len (service_datas ) == 2 :
1210- transition = service_datas [0 ].get (ATTR_TRANSITION )
1211- if transition is not None :
1212- await asyncio .sleep (transition )
1213- await asyncio .sleep (self ._send_split_delay / 1000.0 )
1214- await turn_on (service_datas [1 ])
1235+ async def _make_cancellable_adaptation_calls (
1236+ self , service_datas : list [ServiceData ], context : Context , light_id : str
1237+ ):
1238+ """Executes a cancellable sequence of adaptation service calls for the given service datas.
12151239
1216- if not self ._separate_turn_on_commands :
1217- await turn_on (service_data )
1218- else :
1219- split_tasks = self .turn_on_off_listener .split_adaptation_tasks
1220- if (previous_task := split_tasks .get (light )) is not None :
1221- previous_task .cancel ()
1222- try :
1223- split_tasks [light ] = asyncio .ensure_future (turn_on_split ())
1224- await split_tasks [light ]
1225- except asyncio .CancelledError :
1226- _LOGGER .debug ("Split adaptation of %s cancelled" , light )
1240+ Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g.,
1241+ to cancel an ongoing adaptation when a light is turned off.
1242+ """
1243+ # Prevent overlap of multiple adaptation sequences
1244+ self .turn_on_off_listener .cancel_ongoing_adaptation_calls (light_id )
1245+
1246+ # Execute adaptation calls within a task
1247+ try :
1248+ task = self .turn_on_off_listener .adaptation_tasks [
1249+ light_id
1250+ ] = asyncio .ensure_future (
1251+ self ._make_adaptation_calls (service_datas , context )
1252+ )
1253+ await task
1254+ except asyncio .CancelledError :
1255+ _LOGGER .debug ("Ongoing adaptation of %s cancelled" , light_id )
12271256
12281257 async def _update_attrs_and_maybe_adapt_lights (
12291258 self ,
@@ -1696,7 +1725,7 @@ def __init__(self, hass: HomeAssistant):
16961725 # Track last 'service_data' to 'light.turn_on' resulting from this integration
16971726 self .last_service_data : dict [str , dict [str , Any ]] = {}
16981727 # Track ongoing split adaptations to be able to cancel them
1699- self .split_adaptation_tasks : dict [str , asyncio .Task ] = {}
1728+ self .adaptation_tasks : dict [str , asyncio .Task ] = {}
17001729
17011730 # Track auto reset of manual_control
17021731 self .auto_reset_manual_control_timers : dict [str , _AsyncSingleShotTimer ] = {}
@@ -1802,6 +1831,11 @@ async def reset():
18021831
18031832 self ._handle_timer (light , self .auto_reset_manual_control_timers , delay , reset )
18041833
1834+ def cancel_ongoing_adaptation_calls (self , light_id : str ):
1835+ """Cancels an ongoing sequence of adaptation service calls for a specific light entity."""
1836+ if (previous_task := self .adaptation_tasks .get (light_id )) is not None :
1837+ previous_task .cancel ()
1838+
18051839 def reset (self , * lights , reset_manual_control = True ) -> None :
18061840 """Reset the 'manual_control' status of the lights."""
18071841 for light in lights :
@@ -1812,9 +1846,7 @@ def reset(self, *lights, reset_manual_control=True) -> None:
18121846 timer .cancel ()
18131847 self .last_state_change .pop (light , None )
18141848 self .last_service_data .pop (light , None )
1815-
1816- if (task := self .split_adaptation_tasks .get (light )) is not None :
1817- task .cancel ()
1849+ self .cancel_ongoing_adaptation_calls (light )
18181850
18191851 async def turn_on_off_event_listener (self , event : Event ) -> None :
18201852 """Track 'light.turn_off' and 'light.turn_on' service calls."""
0 commit comments