Compare commits

...

20 commits

Author SHA1 Message Date
Benjamin Auquite
96f59f0900 fix the test
last_state_change isn't updated quick enough.
2023-04-03 03:29:38 -05:00
github-actions[bot]
a649198cf4 Update README.md 2023-04-03 07:38:29 +00:00
Benjamin Auquite
26c32ac3d8
Merge 3731c99936 into 9caf3048f1 2023-04-03 07:36:57 +00:00
Bas Nijholt
3731c99936 Revert "Small refactor"
This reverts commit b986b3f77c.
2023-04-03 00:32:33 -07:00
Bas Nijholt
b4c6bf8a25 Move test to old position for better diffs 2023-04-03 00:22:44 -07:00
Bas Nijholt
b986b3f77c Small refactor 2023-04-03 00:20:12 -07:00
Benjamin Auquite
4479761ad7 Update switch.py 2023-04-03 01:50:52 -05:00
Benjamin Auquite
2f0bad82ff Update switch.py 2023-04-03 01:40:29 -05:00
Benjamin Auquite
cebf31a203 Update switch.py 2023-04-03 01:35:53 -05:00
Benjamin Auquite
836595051d Revert "cleanup"
This reverts commit 3aa2f3242b.
2023-04-03 01:02:42 -05:00
Benjamin Auquite
3aa2f3242b cleanup 2023-04-03 00:55:21 -05:00
Benjamin Auquite
732fc17c7f merge related fix 2023-04-02 23:56:08 -05:00
Benjamin Auquite
1571dfaeda Update switch.py 2023-04-02 23:46:20 -05:00
Benjamin Auquite
120efff71e update tests 2023-04-02 23:43:30 -05:00
Benjamin Auquite
30c62bf759 Update switch.py 2023-04-02 23:28:58 -05:00
Benjamin Auquite
8e847e71eb not renamed in this branch yet. 2023-04-02 23:15:53 -05:00
Benjamin Auquite
22bae9ed84 Update switch.py 2023-04-02 23:08:35 -05:00
Benjamin Auquite
8b2fcf93be cherry-pick wait for transition stuff 2023-04-02 23:04:26 -05:00
Benjamin Auquite
d7aecc9e7f
Merge branch 'main' into wait_for_transitions 2023-04-02 23:01:32 -05:00
Bas Nijholt
52275bfee3 Add auto_reset_manual_control with async timer 2023-03-28 00:19:35 -07:00
3 changed files with 200 additions and 81 deletions

View file

@ -96,10 +96,10 @@ The YAML and frontend configuration methods support all of the options listed be
| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | | `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s |
| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` | | `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
| `adapt_until_sleep` | When `true`, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | `1` | `float` 0-6553 | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | `1` | `float` 0-6553 |
| `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 | | `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 |
| `transition_until_sleep` | When checked, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` |
| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` |
| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | | `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 |
| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | | `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 |

View file

@ -818,10 +818,22 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._only_once = data[CONF_ONLY_ONCE] self._only_once = data[CONF_ONLY_ONCE]
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
self._transition = data[CONF_TRANSITION] self._transition = data[CONF_TRANSITION]
self._adapt_delay = data[CONF_ADAPT_DELAY] self._adapt_delay = data[CONF_ADAPT_DELAY]
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
if not data[CONF_TAKE_OVER_CONTROL] and (data[CONF_DETECT_NON_HA_CHANGES]):
_LOGGER.warn(
"%s: Config mismatch: 'detect_non_ha_changes: true' "
" are set in config, however required"
" variable 'take_over_control' is turned off. Please check your"
" configuration to ensure desired functionality. We will now"
" enable 'take_over_control' and continue setting up the"
" adaptive-lighting integration normally.",
self._name,
)
self._take_over_control = True
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
self._expand_light_groups() # updates manual control timers self._expand_light_groups() # updates manual control timers
_loc = get_astral_location(self.hass) _loc = get_astral_location(self.hass)
@ -1132,10 +1144,21 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
) )
) )
self.async_write_ha_state() self.async_write_ha_state()
if lights is None: if lights is None:
lights = self._lights lights = self._lights
if (self._only_once and not force) or not lights:
if not force:
if self._only_once:
return
for light in lights:
# Don't adapt lights that haven't finished prior transitions.
if self.turn_on_off_listener.transition_timers.get(light):
lights.remove(light)
if not len(lights):
return return
await self._adapt_lights(lights, transition, force, context) await self._adapt_lights(lights, transition, force, context)
async def _adapt_lights( async def _adapt_lights(
@ -1544,6 +1567,9 @@ class TurnOnOffListener:
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
self.auto_reset_manual_control_times: dict[str, float] = {} self.auto_reset_manual_control_times: dict[str, float] = {}
# Track light transitions
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
# When a state is different `max_cnt_significant_changes` times in a row, # When a state is different `max_cnt_significant_changes` times in a row,
# mark it as manually_controlled. # mark it as manually_controlled.
self.max_cnt_significant_changes = 2 self.max_cnt_significant_changes = 2
@ -1555,6 +1581,49 @@ class TurnOnOffListener:
EVENT_STATE_CHANGED, self.state_changed_event_listener EVENT_STATE_CHANGED, self.state_changed_event_listener
) )
def start_transition_timer(self, light: str) -> None:
"""Mark a light as manually controlled."""
_LOGGER.debug("Start transition timer for %s", light)
last_service_data = self.last_service_data
if (
not last_service_data
or light not in last_service_data
or ATTR_TRANSITION not in last_service_data[light]
):
return False
delay = last_service_data[light][ATTR_TRANSITION]
timer = self.transition_timers.get(light)
if timer is not None:
if delay is None: # Timer object exists, but should not anymore
timer.cancel()
self.transition_timers.pop(light)
else: # Timer object already exists, just update the delay and restart it
timer.delay = delay
timer.start()
elif delay is not None: # Timer object does not exist, create it
async def reset():
_LOGGER.debug(
"Transition finished for light %s",
light,
)
# This part is optional, we could just wait for the next interval.
switches = _get_switches_with_lights(self.hass, [light])
for switch in switches:
if not switch.is_on:
continue
# pylint: disable=protected-access
await switch._update_attrs_and_maybe_adapt_lights(
[light],
force=False,
context=switch.create_context("transit"),
)
timer = _AsyncSingleShotTimer(delay, reset)
self.transition_timers[light] = timer
timer.start()
def set_auto_reset_manual_control_times(self, lights: list[str], time: float): def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
"""Set the time after which the lights are automatically reset.""" """Set the time after which the lights are automatically reset."""
if time == 0: if time == 0:
@ -1622,7 +1691,6 @@ class TurnOnOffListener:
timer.cancel() timer.cancel()
self.last_state_change.pop(light, None) self.last_state_change.pop(light, None)
self.last_service_data.pop(light, None) self.last_service_data.pop(light, None)
self.cnt_significant_changes[light] = 0
async def turn_on_off_event_listener(self, event: Event) -> None: async def turn_on_off_event_listener(self, event: Event) -> None:
"""Track 'light.turn_off' and 'light.turn_on' service calls.""" """Track 'light.turn_off' and 'light.turn_on' service calls."""
@ -1701,11 +1769,7 @@ class TurnOnOffListener:
new_state.context.id, new_state.context.id,
) )
if ( if new_state is not None and new_state.state == STATE_ON:
new_state is not None
and new_state.state == STATE_ON
and is_our_context(new_state.context)
):
# It is possible to have multiple state change events with the same context. # It is possible to have multiple state change events with the same context.
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
# event leads to an instant state change of # event leads to an instant state change of
@ -1718,21 +1782,29 @@ class TurnOnOffListener:
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
# Philips Hue White GU10 Bluetooth lights). # Philips Hue White GU10 Bluetooth lights).
old_state: list[State] | None = self.last_state_change.get(entity_id) old_state: list[State] | None = self.last_state_change.get(entity_id)
if ( if is_our_context(new_state.context):
old_state is not None if (
and old_state[0].context.id == new_state.context.id old_state is not None
): and old_state[0].context.id == new_state.context.id
# If there is already a state change event from this event (with this ):
# context) then append it to the already existing list. _LOGGER.debug(
_LOGGER.debug( "TurnOnOffListener: State change event of '%s' is already"
"State change event of '%s' is already in 'self.last_state_change' (%s)" " in 'self.last_state_change' (%s)"
" adding this state also", " adding this state also",
entity_id, entity_id,
new_state.context.id, new_state.context.id,
) )
self.last_state_change[entity_id].append(new_state)
else:
_LOGGER.debug(
"TurnOnOffListener: New adapt '%s' found for %s",
new_state,
entity_id,
)
self.last_state_change[entity_id] = [new_state]
self.start_transition_timer(entity_id)
elif old_state is not None:
self.last_state_change[entity_id].append(new_state) self.last_state_change[entity_id].append(new_state)
else:
self.last_state_change[entity_id] = [new_state]
def is_manually_controlled( def is_manually_controlled(
self, self,
@ -1787,64 +1859,58 @@ class TurnOnOffListener:
detected, we mark the light as 'manually controlled' until the light detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again. or switch is turned 'off' and 'on' again.
""" """
if light not in self.last_state_change: last_service_data = self.last_service_data.get(light)
return False if last_service_data is None:
old_states: list[State] = self.last_state_change[light] return
await self.hass.helpers.entity_component.async_update_entity(light)
new_state = self.hass.states.get(light)
compare_to = functools.partial( compare_to = functools.partial(
_attributes_have_changed, _attributes_have_changed,
light=light, light=light,
new_attributes=new_state.attributes,
adapt_brightness=adapt_brightness, adapt_brightness=adapt_brightness,
adapt_color=adapt_color, adapt_color=adapt_color,
context=context, context=context,
) )
for index, old_state in enumerate(old_states): # Update state and check for a manual change not done in HA.
changed = compare_to(old_attributes=old_state.attributes) # Ensure HASS is correctly updating your light's state with
if not changed: # light.turn_on calls if any problems arise. This
_LOGGER.debug( # can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)", if switch._detect_non_ha_changes:
light, _LOGGER.debug(
index, "%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
context.id, " and check if it's last adapt succeeded.",
) switch._name,
break light,
)
last_service_data = self.last_service_data.get(light) # This update_entity probably isn't necessary now that we're checking
if changed and last_service_data is not None: # if transitions finished from our last adapt.
# It can happen that the state change events that are associated await self.hass.helpers.entity_component.async_update_entity(light)
# with the last 'light.turn_on' call by this integration were not refreshed_state = self.hass.states.get(light)
# final states. Possibly a later EVENT_STATE_CHANGED happened, where _LOGGER.debug(
# the correct target brightness/color was reached. "%s: Current state of %s: %s",
changed = compare_to(old_attributes=last_service_data) switch._name,
if not changed: light,
refreshed_state,
)
changed = compare_to(
old_attributes=last_service_data,
new_attributes=refreshed_state.attributes,
)
if changed:
_LOGGER.debug( _LOGGER.debug(
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
light, light,
context.id, context.id,
) )
n_changes = self.cnt_significant_changes[light]
if changed:
self.cnt_significant_changes[light] += 1
if n_changes >= self.max_cnt_significant_changes:
# Only mark a light as significantly changing, if changed==True
# N times in a row. We do this because sometimes a state changes
# happens only *after* a new update interval has already started.
self.mark_as_manual_control(light) self.mark_as_manual_control(light)
_fire_manual_control_event(switch, light, context, is_async=False) _fire_manual_control_event(switch, light, context, is_async=False)
else: return True
if n_changes > 1: _LOGGER.debug(
_LOGGER.debug( "%s: Light '%s' correctly matches our last adapt's service data, continuing..."
"State of '%s' had 'cnt_significant_changes=%s' but the state" " context.id=%s.",
" changed to the expected settings now", switch._name,
light, light,
n_changes, context.id,
) )
self.cnt_significant_changes[light] = 0 return False
return changed
async def maybe_cancel_adjusting( async def maybe_cancel_adjusting(
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None

View file

@ -21,6 +21,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_SUNRISE_OFFSET, CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME, CONF_SUNRISE_TIME,
CONF_SUNSET_TIME, CONF_SUNSET_TIME,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION, CONF_TRANSITION,
CONF_TURN_ON_LIGHTS, CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS, CONF_USE_DEFAULTS,
@ -56,6 +57,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_AREA_ID, ATTR_AREA_ID,
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS, CONF_LIGHTS,
CONF_NAME, CONF_NAME,
CONF_PLATFORM, CONF_PLATFORM,
@ -197,7 +199,8 @@ async def setup_lights_and_switch(hass, extra_conf=None):
CONF_SUNSET_TIME: datetime.time(SUNSET.hour), CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
CONF_INITIAL_TRANSITION: 0, CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0, CONF_TRANSITION: 0,
CONF_DETECT_NON_HA_CHANGES: True, CONF_DETECT_NON_HA_CHANGES: False,
CONF_TAKE_OVER_CONTROL: True,
CONF_PREFER_RGB_COLOR: False, CONF_PREFER_RGB_COLOR: False,
CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp
**(extra_conf or {}), **(extra_conf or {}),
@ -762,25 +765,75 @@ async def test_significant_change(hass):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
async def change_switch_settings(**kwargs):
await hass.services.async_call(
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{
ATTR_ENTITY_ID: ENTITY_SWITCH,
**kwargs,
},
blocking=True,
)
await hass.async_block_till_done()
async def set_brightness(val: int):
hass.states.async_set(
ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1}
)
await hass.async_block_till_done()
async def do_nothing(entity_id):
_LOGGER.debug("update entity successfully replaced for %s", entity_id)
return None
switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass) switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass)
_LOGGER.debug("Test detect_non_ha_changes:")
switch._take_over_control = True
assert switch._take_over_control
switch._detect_non_ha_changes = True
assert switch._detect_non_ha_changes
# build last service data
await update(force=False)
# force=True should not reset manual control.
await turn_light(True, brightness=40)
await turn_light(True, brightness=20)
await update(force=False)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
await update(force=True)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# turn light off then on should reset manual control.
await turn_light(False)
await turn_light(True) await turn_light(True)
await update(force=True) # removes manual control
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# Change brightness by setting state (not using 'light.turn_on') # Assert last_service_data got filled from update()
attributes = hass.states.get(ENTITY_LIGHT).attributes await update(force=True)
new_attributes = attributes.copy()
new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255
new_attributes[ATTR_BRIGHTNESS] = new_brightness
bed_light_instance._brightness = new_brightness
assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes):
await update(force=False) # Simulate a transition to 255 where the update() is already using brightness 255.
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] await set_brightness(240)
# On next update the light should be marked as manually controlled await set_brightness(244)
await set_brightness(247)
await set_brightness(250)
# last_state_change should have our state changes.
# Change brightness by async_set (not using 'light.turn_on')
new_brightness = 50
await set_brightness(new_brightness)
_LOGGER.debug("Test: Brightness set to %s", new_brightness)
# Override update_entity() to do nothing. Otherwise what happens is
# update_entity() refreshes the state to the last call of
# light.turn_on().
switch.hass.helpers.entity_component.async_update_entity = do_nothing
# On next update ENTITY_LIGHT should be marked as manually controlled
await update(force=False) await update(force=False)
# TODO: the state should be `bool(manual_control) is True` assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
def test_color_difference_redmean(): def test_color_difference_redmean():