Implement call intercept for multiple lights (#679)

* Implement call intercept for multiple lights

* remove comment

* skip if no eids

* add comment

* fix type

* Fix skipped

* indentation

* Add logging and fix error

* Fix for HA ≤2023.04

* simplify

* remove unused ignores

* Debug mode

* Add test

* Make test failing

* rename switch

* rename lights

* Fix tests

* Rename lights in tests

* Remove unused dependencies

* Improve tests

* More tests

* Remove the DEBUG_MODE

* Add doc-string

* Extra test

* assert

* extra test

* Comments

* fix

* fix

* expand light groups

* more logging

* sort

* Revert is_proactively_adapting checks
This reverts commit 39fd8f2be0.

* simplify the mapping

* Revert "Revert is_proactively_adapting checks"

This reverts commit 18803e8e50.

* test

* no light groups

* do not expand

* Do not expand_light_groups in intercept

* more logging

* Fix

* add comment

* Add multi_light_intercept config option

* Update README.md, strings.json, and services.yaml

* add light group

* fix platform

* add simple test

* turn off again

* Test without take over control

* improve test and fix it in one way

* Fixes

* add cleanup fixture

* format

* Update test_switch.py

* add __str__

* remove unneeded call

* simplify service_data construction

* Generalize is_our_context

* Fix multi_light_intercept: false

* add comments

* add docs

* Update README.md, strings.json, and services.yaml

* Add feature line

* move function

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Bas Nijholt 2023-08-03 17:47:09 -07:00 • committed by GitHub
commit 1ef7ed507e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 646 additions and 120 deletions

View file

@ -150,6 +150,20 @@ class AdaptationData:
"""Return data for the next service call, or none if no more data exists."""
return await anext(self.service_call_datas, None)
def __str__(self) -> str:
"""Return a string representation of the data."""
return (
f"{self.__class__.__name__}("
f"entity_id={self.entity_id}, "
f"context_id={self.context.id}, "
f"sleep_time={self.sleep_time}, "
f"force={self.force}, "
f"max_length={self.max_length}, "
f"which={self.which}, "
f"initial_sleep={self.initial_sleep}"
")"
)
class NoColorOrBrightnessInServiceDataError(Exception):
"""Exception raised when no color or brightness attributes are found in service data."""

View file

@ -186,10 +186,20 @@ CONF_SKIP_REDUNDANT_COMMANDS, DEFAULT_SKIP_REDUNDANT_COMMANDS = (
DOCS[CONF_SKIP_REDUNDANT_COMMANDS] = (
"Skip sending adaptation commands whose target state already "
"equals the light's known state. Minimizes network traffic and improves the "
"adaptation responsivity in some situations. "
"adaptation responsivity in some situations. 📉"
"Disable if physical light states get out of sync with HA's recorded state."
)
CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = (
"multi_light_intercept",
True,
)
DOCS[CONF_MULTI_LIGHT_INTERCEPT] = (
"Intercept and adapt `light.turn_on` calls that target multiple lights. ➗"
"⚠️ This might result in splitting up a single `light.turn_on` call "
"into multiple calls, e.g., when lights are in different switches."
)
SLEEP_MODE_SWITCH = "sleep_mode_switch"
ADAPT_COLOR_SWITCH = "adapt_color_switch"
ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch"
@ -290,6 +300,7 @@ VALIDATION_TUPLES = [
DEFAULT_SKIP_REDUNDANT_COMMANDS,
bool,
),
(CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool),
]

View file

@ -48,7 +48,8 @@
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state."
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.",
"multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches."
}
}
},

View file

@ -20,6 +20,7 @@ import ulid_transform
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
@ -119,6 +120,7 @@ from .const import (
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MIN_SUNSET_TIME,
CONF_MULTI_LIGHT_INTERCEPT,
CONF_ONLY_ONCE,
CONF_PREFER_RGB_COLOR,
CONF_SEND_SPLIT_DELAY,
@ -265,30 +267,39 @@ def create_context(
return Context(id=context_id, parent_id=parent_id)
def is_our_context_id(context_id: str | None) -> bool:
def is_our_context_id(context_id: str | None, which: str | None = None) -> bool:
"""Check whether this integration created 'context_id'."""
if context_id is None:
return False
return f":{_DOMAIN_SHORT}:" in context_id
is_al = f":{_DOMAIN_SHORT}:" in context_id
if not is_al:
return False
if which is None:
return True
return f":{_remove_vowels(which)}:" in context_id
def is_our_context(context: Context | None) -> bool:
def is_our_context(context: Context | None, which: str | None = None) -> bool:
"""Check whether this integration created 'context'."""
if context is None:
return False
return is_our_context_id(context.id)
return is_our_context_id(context.id, which)
@bind_hass
def _switches_with_lights(
hass: HomeAssistant,
lights: list[str],
expand_light_groups: bool = True,
) -> list[AdaptiveSwitch]:
"""Get all switches that control at least one of the lights passed."""
config_entries = hass.config_entries.async_entries(DOMAIN)
data = hass.data[DOMAIN]
switches = []
all_check_lights = _expand_light_groups(hass, lights)
all_check_lights = (
_expand_light_groups(hass, lights) if expand_light_groups else set(lights)
)
for config in config_entries:
entry = data.get(config.entry_id)
if entry is None: # entry might be disabled and therefore missing
@ -309,9 +320,10 @@ class NoSwitchFoundError(ValueError):
def _switch_with_lights(
hass: HomeAssistant,
lights: list[str],
expand_light_groups: bool = True,
) -> AdaptiveSwitch:
"""Find the switch that controls the lights in 'lights'."""
switches = _switches_with_lights(hass, lights)
switches = _switches_with_lights(hass, lights, expand_light_groups)
if len(switches) == 1:
return switches[0]
if len(switches) > 1:
@ -643,7 +655,10 @@ def _is_state_event(event: Event, from_or_to_state: Iterable[str]):
@bind_hass
def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
def _expand_light_groups(
hass: HomeAssistant,
lights: list[str],
) -> list[str]:
all_lights = set()
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
for light in lights:
@ -651,14 +666,18 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
if state is None:
_LOGGER.debug("State of %s is None", light)
all_lights.add(light)
elif "entity_id" in state.attributes: # it's a light group
elif _is_light_group(state):
group = state.attributes["entity_id"]
manager.lights.discard(light)
all_lights.update(group)
_LOGGER.debug("Expanded %s to %s", light, group)
else:
all_lights.add(light)
return list(all_lights)
return sorted(all_lights)
def _is_light_group(state: State) -> bool:
return "entity_id" in state.attributes
@bind_hass
@ -927,6 +946,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._take_over_control = True
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS]
self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT]
self._expand_light_groups() # updates manual control timers
location, _ = get_astral_location(self.hass)
@ -1272,11 +1292,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
prefer_rgb_color: bool | None = None,
force: bool = False,
) -> None:
# This should never happen if it's been proactively adapted.
# The context.parent_id is the context.id of the service call that was intercepted
# and context.id here is from the resulting "light_event" event.
assert not self.manager.is_proactively_adapting(context.parent_id)
if (lock := self.manager.turn_off_locks.get(light)) and lock.locked():
_LOGGER.debug("%s: '%s' is locked", self._name, light)
return
@ -1874,6 +1889,9 @@ class AdaptiveLightingManager:
# Track light transitions
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
# Track _execute_cancellable_adaptation_calls tasks
self.adaptation_tasks = set()
# Setup listeners and its callbacks to remove them later
self.listener_removers = [
self.hass.bus.async_listen(
@ -1958,65 +1976,235 @@ class AdaptiveLightingManager:
for key in keys:
self._proactively_adapting_contexts.pop(key)
async def _service_interceptor_turn_on_handler( # noqa: PLR0911
async def _service_interceptor_turn_on_handler( # noqa: PLR0912, PLR0915
self,
call: ServiceCall,
data: ServiceData,
):
# Don't adapt our own service calls
if is_our_context(call.context):
) -> None:
"""Intercept `light.turn_on` and `light.toggle` service calls and adapt them.
It is possible that the calls are made for multiple lights at once,
which in turn might be in different switches or no switches at all.
If there are lights that are not all in a single switch, we need to
make multiple calls to `light.turn_on` with the correct entity IDs.
One of these calls can be intercepted and adapted, the others need to
be adapted by calling `_adapt_light` with the correct entity IDs or
by calling `light.turn_on` directly.
We create a mapping from switch to entity IDs and keep a list
of skipped lights which are lights in no switches or in switches that
are off or lights that are already on.
If there is only one switch and 0 skipped lights, we just intercept the
call directly.
If there are multiple switches and skipped lights, we can adapt the call
for one of the switches to include only the lights in that switch and
need to call `_adapt_light` for the other switches with their
entity_ids. For skipped lights, we call light.turn_on directly with the
entity_ids and original service data.
If there are only skipped lights, we can use the intercepted call
directly.
"""
is_skipped_hash = is_our_context(call.context, "skipped")
_LOGGER.debug(
"(0) _service_interceptor_turn_on_handler: call.context.id='%s', is_skipped_hash='%s'",
call.context.id,
is_skipped_hash,
)
if is_our_context(call.context) and not is_skipped_hash:
# Don't adapt our own service calls, but do re-adapt calls that
# were skipped by us
return
if ATTR_EFFECT in data[CONF_PARAMS] or ATTR_FLASH in data[CONF_PARAMS]:
return
_LOGGER.debug(
"(1) _service_interceptor_turn_on_handler: call='%s', data='%s'",
call,
data,
)
entity_ids = self._get_entity_list(data)
# Note: we do not expand light groups anywhere in this method, instead
# we skip them and rely on the followup call that HA will make
# with the expanded entity IDs.
# For simplicity, only service calls affecting a single entity are currently handled.
#
# To add support for adapting multiple entities, the following properties
# need to hold for _all_ entities:
# - managed by this AL instance
# - not manually controlled
# - supporting the same relevant feature set
# - off state
if len(entity_ids) != 1:
return
# Create a mapping from switch to entity IDs
# AdaptiveSwitch.name → entity_ids mapping
switch_to_eids: dict[str, list[str]] = {}
# AdaptiveSwitch.name → AdaptiveSwitch mapping
switch_name_mapping: dict[str, AdaptiveSwitch] = {}
# Note: In HA≥2023.5, AdaptiveSwitch is hashable, so we can
# use dict[AdaptiveSwitch, list[str]]
skipped: list[str] = []
for entity_id in entity_ids:
try:
switch = _switch_with_lights(
self.hass,
[entity_id],
# Do not expand light groups, because HA will make a separate light.turn_on
# call where the lights are expanded, and that call will be intercepted.
expand_light_groups=False,
)
except NoSwitchFoundError:
# Needs to make the original call but without adaptation
skipped.append(entity_id)
_LOGGER.debug(
"No switch found for entity_id='%s', skipped='%s'",
entity_id,
skipped,
)
else:
if (
not switch.is_on
# Never adapt on light groups, because HA will make a separate light.turn_on
or _is_light_group(self.hass.states.get(entity_id))
# Prevent adaptation of TURN_ON calls when light is already on,
# and of TOGGLE calls when toggling off.
or self.hass.states.is_state(entity_id, STATE_ON)
or self.manual_control.get(entity_id, False)
):
_LOGGER.debug(
"Switch is off or light is already on for entity_id='%s', skipped='%s'"
" (is_on='%s', is_state='%s', manual_control='%s')",
entity_id,
skipped,
switch.is_on,
self.hass.states.is_state(entity_id, STATE_ON),
self.manual_control.get(entity_id, False),
)
skipped.append(entity_id)
else:
switch_to_eids.setdefault(switch.name, []).append(entity_id)
switch_name_mapping[switch.name] = switch
entity_id = entity_ids[0]
# Prevent adaptation of TURN_ON calls when light is already on,
# and of TOGGLE calls when toggling off.
if self.hass.states.is_state(entity_id, STATE_ON):
return
if self.manual_control.get(entity_id, False):
return
if data.get(ATTR_BRIGHTNESS) == 0:
# Check for `multi_light_intercept: true/false`
mli = [sw._multi_light_intercept for sw in switch_name_mapping.values()]
more_than_one_switch = len(switch_to_eids) > 1
single_switch_with_multiple_lights = (
len(switch_to_eids) == 1 and len(next(iter(switch_to_eids.values()))) > 1
)
switch_without_multi_light_intercept = not all(mli)
if more_than_one_switch and switch_without_multi_light_intercept:
_LOGGER.warning(
"Turn-on call with zero brightness detected, Adaptive Lighting"
" intercepted this service_call and adjusted it. If you use this as"
" a brightness workaround, please remove it, it is no longer necessary",
"Multiple switches (%s) targeted, but not all have"
" `multi_light_intercept: true`, so skipping intercept"
" for all lights.",
switch_to_eids,
)
skipped = entity_ids
switch_to_eids = {}
elif (
single_switch_with_multiple_lights and switch_without_multi_light_intercept
):
_LOGGER.warning(
"Single switch with multiple lights targeted, but"
" `multi_light_intercept: true` is not set, so skipping intercept"
" for all lights.",
switch_to_eids,
)
skipped = entity_ids
switch_to_eids = {}
try:
adaptive_switch = _switch_with_lights(self.hass, [entity_id])
except NoSwitchFoundError:
# This might be a light that is not managed by this AL instance.
_LOGGER.debug(
"(2) _service_interceptor_turn_on_handler: switch_to_eids='%s', skipped='%s'",
switch_to_eids,
skipped,
)
def modify_service_data(service_data, entity_ids):
"""Modify the service data to contain the entity IDs."""
service_data.pop(ATTR_ENTITY_ID, None)
service_data.pop(ATTR_AREA_ID, None)
service_data[ATTR_ENTITY_ID] = entity_ids
return service_data
# Intercept the call for first switch and call _adapt_light for the rest
has_intercepted = False # Can only intercept a turn_on call once
for adaptive_switch_name, _entity_ids in switch_to_eids.items():
switch = switch_name_mapping[adaptive_switch_name]
transition = data[CONF_PARAMS].get(
ATTR_TRANSITION,
switch.initial_transition,
)
if not has_intercepted:
_LOGGER.debug(
"(3) _service_interceptor_turn_on_handler: intercepting entity_ids='%s'",
_entity_ids,
)
await self._service_interceptor_turn_on_single_light_handler(
entity_ids=_entity_ids,
switch=switch,
transition=transition,
call=call,
data=modify_service_data(data, _entity_ids),
)
has_intercepted = True
continue
for eid in _entity_ids:
# Must add a new context otherwise _adapt_light will bail out
context = switch.create_context("intercept")
self.clear_proactively_adapting(eid)
self.set_proactively_adapting(context.id, eid)
_LOGGER.debug(
"(4) _service_interceptor_turn_on_handler: calling `_adapt_light` with eid='%s', context='%s', transition='%s'",
eid,
context,
transition,
)
await switch._adapt_light(
light=eid,
context=context,
transition=transition,
)
# Call light.turn_on service for skipped entities
if skipped:
if not has_intercepted:
assert set(skipped) == set(entity_ids)
return # The call will be intercepted with the original data
# Call light turn_on service for skipped entities
context = switch.create_context("skipped")
_LOGGER.debug(
"No (or multiple) adaptive switch(es) found for entity %s,"
" skipping adaptation by intercepting service call",
entity_id,
"(5) _service_interceptor_turn_on_handler: calling `light.turn_on` with skipped='%s', data: '%s', context='%s'",
skipped,
data,
context.id,
)
# Need to expand light groups here because otherwise this interceptor loop will happen twice more
_LOGGER.debug(
"(6) _service_interceptor_turn_on_handler: calling `light.turn_on` with skipped='%s', data: '%s', context='%s'",
skipped,
data,
context.id,
)
service_data = {ATTR_ENTITY_ID: skipped, **data[CONF_PARAMS]}
if (
ATTR_COLOR_TEMP in service_data
and ATTR_COLOR_TEMP_KELVIN in service_data
):
# ATTR_COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN are mutually exclusive
del service_data[ATTR_COLOR_TEMP]
await self.hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
service_data,
blocking=True,
context=context,
)
return
if not adaptive_switch.is_on:
return
if entity_id not in adaptive_switch.lights:
return
async def _service_interceptor_turn_on_single_light_handler(
self,
entity_ids: list[str],
switch: AdaptiveSwitch,
transition: int,
call: ServiceCall,
data: ServiceData,
):
_LOGGER.debug(
"Intercepted TURN_ON call with data %s (%s)",
data,
@ -2024,18 +2212,13 @@ class AdaptiveLightingManager:
)
# Reset because turning on the light, this also happens in
# `turn_on_off_event_listener`, however, this function is called
# `state_changed_event_listener`, however, this function is called
# before that one.
self.reset(entity_id, reset_manual_control=False)
self.reset(*entity_ids, reset_manual_control=False)
for entity_id in entity_ids:
self.clear_proactively_adapting(entity_id)
self.clear_proactively_adapting(entity_id)
transition = data[CONF_PARAMS].get(
ATTR_TRANSITION,
adaptive_switch.initial_transition,
)
adaptation_data = await adaptive_switch.prepare_adaptation_data(
adaptation_data = await switch.prepare_adaptation_data(
entity_id,
transition,
)
@ -2063,12 +2246,21 @@ class AdaptiveLightingManager:
# We cannot know here whether there is another call to follow (since the
# state can change until the next call), so we just schedule it and let
# it sort out by itself.
self.set_proactively_adapting(call.context.id, entity_id)
self.set_proactively_adapting(adaptation_data.context.id, entity_id)
for entity_id in entity_ids:
self.set_proactively_adapting(call.context.id, entity_id)
self.set_proactively_adapting(adaptation_data.context.id, entity_id)
adaptation_data.initial_sleep = True
_ = asyncio.create_task( # Don't await to avoid blocking the service call
adaptive_switch.execute_cancellable_adaptation_calls(adaptation_data),
# Don't await to avoid blocking the service call.
# Assign to a variable only to await in tests.
self.adaptation_tasks.add(
asyncio.create_task(
switch.execute_cancellable_adaptation_calls(adaptation_data),
),
)
# Remove tasks that are done
if done_tasks := [t for t in self.adaptation_tasks if t.done()]:
self.adaptation_tasks.difference_update(done_tasks)
def _handle_timer(
self,
@ -2092,14 +2284,22 @@ class AdaptiveLightingManager:
def start_transition_timer(self, light: str) -> None:
"""Mark a light as manually controlled."""
last_service_data = self.last_service_data[light]
last_transition = last_service_data.get(ATTR_TRANSITION)
if not last_transition:
last_service_data = self.last_service_data.get(light)
if last_service_data is None:
_LOGGER.debug(
"No transition in last adapt for light %s, continuing...",
"No last service data for light %s, not starting timer.",
light,
)
return
last_transition = last_service_data.get(ATTR_TRANSITION)
if not last_transition:
_LOGGER.debug(
"No transition in last adapt for light %s, not starting timer.",
light,
)
return
_LOGGER.debug(
"Start transition timer of %s seconds for light %s",
last_transition,
@ -2198,8 +2398,7 @@ class AdaptiveLightingManager:
for light in lights:
if reset_manual_control:
self.manual_control[light] = False
timer = self.auto_reset_manual_control_timers.pop(light, None)
if timer is not None:
if timer := self.auto_reset_manual_control_timers.pop(light, None):
timer.cancel()
self.our_last_state_on_change.pop(light, None)
self.last_service_data.pop(light, None)
@ -2380,6 +2579,7 @@ class AdaptiveLightingManager:
entity_id,
event.context.id,
)
# Note: the reset below already happened in `_service_interceptor_turn_on_handler`
return
self.reset(entity_id, reset_manual_control=False)

View file

@ -49,7 +49,8 @@
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state."
"skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.",
"multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches."
}
}
},