diff --git a/.all-contributorsrc b/.all-contributorsrc
index 344b3112..3872e09b 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -400,7 +400,8 @@
"avatar_url": "https://avatars.githubusercontent.com/u/2219836?v=4",
"profile": "https://github.com/th3w1zard1",
"contributions": [
- "code"
+ "code",
+ "bug"
]
},
{
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2f8938a7..462e0dc2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,7 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/psf/black
- rev: 23.1.0
+ rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/asottile/pyupgrade
diff --git a/README.md b/README.md
index c7a873d0..65fc0d74 100644
--- a/README.md
+++ b/README.md
@@ -445,7 +445,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting
 Tomáš Valigura 🌍 |
-  Benjamin Auquite 💻 |
+  Benjamin Auquite 💻 🐛 |
 Skyler Carlson 📖 |
 Chris 💻 |
 Raman Gupta 💻 |
diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json
index cb7ba6e1..33564f23 100644
--- a/custom_components/adaptive_lighting/manifest.json
+++ b/custom_components/adaptive_lighting/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
"requirements": [],
- "version": "1.9.1"
+ "version": "1.10.0"
}
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index f72091dc..d6b8ee1e 100644
--- a/custom_components/adaptive_lighting/switch.py
+++ b/custom_components/adaptive_lighting/switch.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import base64
import bisect
-from collections import defaultdict
+from collections.abc import Callable, Coroutine
from copy import deepcopy
from dataclasses import dataclass
import datetime
@@ -802,10 +802,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._only_once = data[CONF_ONLY_ONCE]
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
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._adapt_delay = data[CONF_ADAPT_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.warning(
+ "%s: Config mismatch: 'detect_non_ha_changes: true' "
+ "requires 'take_over_control' to be enabled. Adjusting config "
+ "and continuing setup with `take_over_control: true`.",
+ self._name,
+ )
+ self._take_over_control = True
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
self._expand_light_groups() # updates manual control timers
_loc = get_astral_location(self.hass)
@@ -1128,11 +1137,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
)
self.async_write_ha_state()
+
if lights is None:
lights = self._lights
- if (self._only_once and not force) or not lights:
+
+ if not force and self._only_once:
return
- await self._adapt_lights(lights, transition, force, context)
+
+ filtered_lights = []
+ for light in lights:
+ # Don't adapt lights that haven't finished prior transitions.
+ if force or not self.turn_on_off_listener.transition_timers.get(light):
+ filtered_lights.append(light)
+
+ if not filtered_lights:
+ return
+
+ await self._adapt_lights(filtered_lights, transition, force, context)
async def _adapt_lights(
self,
@@ -1541,8 +1562,6 @@ class TurnOnOffListener:
self.sleep_tasks: dict[str, asyncio.Task] = {}
# Tracks which lights are manually controlled
self.manual_control: dict[str, bool] = {}
- # Counts the number of times (in a row) a light had a changed state.
- self.cnt_significant_changes: dict[str, int] = defaultdict(int)
# Track 'state_changed' events of self.lights resulting from this integration
self.last_state_change: dict[str, list[State]] = {}
# Track last 'service_data' to 'light.turn_on' resulting from this integration
@@ -1552,9 +1571,8 @@ class TurnOnOffListener:
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
self.auto_reset_manual_control_times: dict[str, float] = {}
- # When a state is different `max_cnt_significant_changes` times in a row,
- # mark it as manually_controlled.
- self.max_cnt_significant_changes = 2
+ # Track light transitions
+ self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
self.remove_listener = self.hass.bus.async_listen(
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
@@ -1563,6 +1581,56 @@ class TurnOnOffListener:
EVENT_STATE_CHANGED, self.state_changed_event_listener
)
+ def _handle_timer(
+ self,
+ light: str,
+ timers_dict: dict[str, _AsyncSingleShotTimer],
+ delay: float | None,
+ reset_coroutine: Callable[[], Coroutine[Any, Any, None]],
+ ) -> None:
+ timer = timers_dict.get(light)
+ if timer is not None:
+ if delay is None: # Timer object exists, but should not anymore
+ timer.cancel()
+ timers_dict.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
+ timer = _AsyncSingleShotTimer(delay, reset_coroutine)
+ timers_dict[light] = timer
+ timer.start()
+
+ 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
+
+ delay = last_service_data[light][ATTR_TRANSITION]
+
+ async def reset():
+ _LOGGER.debug(
+ "Transition finished for light %s",
+ light,
+ )
+ switches = _get_switches_with_lights(self.hass, [light])
+ for switch in switches:
+ if not switch.is_on:
+ continue
+ await switch._update_attrs_and_maybe_adapt_lights(
+ [light],
+ force=False,
+ context=switch.create_context("transit"),
+ )
+
+ self._handle_timer(light, self.transition_timers, delay, reset)
+
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
"""Set the time after which the lights are automatically reset."""
if time == 0:
@@ -1585,40 +1653,28 @@ class TurnOnOffListener:
_LOGGER.debug("Marking '%s' as manually controlled.", light)
self.manual_control[light] = True
delay = self.auto_reset_manual_control_times.get(light)
- timer = self.auto_reset_manual_control_timers.get(light)
- if timer is not None:
- if delay is None: # Timer object exists, but should not anymore
- timer.cancel()
- self.auto_reset_manual_control_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():
- self.reset(light)
- 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],
- transition=switch._initial_transition,
- force=True,
- context=switch.create_context("autoreset"),
- )
- _LOGGER.debug(
- "Auto resetting 'manual_control' status of '%s' because"
- " it was not manually controlled for %s seconds.",
- light,
- delay,
+ async def reset():
+ self.reset(light)
+ switches = _get_switches_with_lights(self.hass, [light])
+ for switch in switches:
+ if not switch.is_on:
+ continue
+ await switch._update_attrs_and_maybe_adapt_lights(
+ [light],
+ transition=switch._initial_transition,
+ force=True,
+ context=switch.create_context("autoreset"),
)
- assert not self.manual_control[light]
+ _LOGGER.debug(
+ "Auto resetting 'manual_control' status of '%s' because"
+ " it was not manually controlled for %s seconds.",
+ light,
+ delay,
+ )
+ assert not self.manual_control[light]
- timer = _AsyncSingleShotTimer(delay, reset)
- self.auto_reset_manual_control_timers[light] = timer
- timer.start()
+ self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
def reset(self, *lights, reset_manual_control=True) -> None:
"""Reset the 'manual_control' status of the lights."""
@@ -1630,7 +1686,6 @@ class TurnOnOffListener:
timer.cancel()
self.last_state_change.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:
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@@ -1709,11 +1764,7 @@ class TurnOnOffListener:
new_state.context.id,
)
- if (
- new_state is not None
- and new_state.state == STATE_ON
- and is_our_context(new_state.context)
- ):
+ if new_state is not None and new_state.state == STATE_ON:
# 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)`
# event leads to an instant state change of
@@ -1726,21 +1777,29 @@ class TurnOnOffListener:
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
# Philips Hue White GU10 Bluetooth lights).
old_state: list[State] | None = self.last_state_change.get(entity_id)
- if (
- 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(
- "State change event of '%s' is already in 'self.last_state_change' (%s)"
- " adding this state also",
- entity_id,
- new_state.context.id,
- )
+ if is_our_context(new_state.context):
+ if (
+ old_state is not None
+ and old_state[0].context.id == new_state.context.id
+ ):
+ _LOGGER.debug(
+ "TurnOnOffListener: State change event of '%s' is already"
+ " in 'self.last_state_change' (%s)"
+ " adding this state also",
+ entity_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)
- else:
- self.last_state_change[entity_id] = [new_state]
def is_manually_controlled(
self,
@@ -1795,64 +1854,58 @@ class TurnOnOffListener:
detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again.
"""
- if light not in self.last_state_change:
- return False
- old_states: list[State] = self.last_state_change[light]
- await self.hass.helpers.entity_component.async_update_entity(light)
- new_state = self.hass.states.get(light)
+ last_service_data = self.last_service_data.get(light)
+ if last_service_data is None:
+ return
compare_to = functools.partial(
_attributes_have_changed,
light=light,
- new_attributes=new_state.attributes,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
context=context,
)
- for index, old_state in enumerate(old_states):
- changed = compare_to(old_attributes=old_state.attributes)
- if not changed:
- _LOGGER.debug(
- "State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
- light,
- index,
- context.id,
- )
- break
-
- last_service_data = self.last_service_data.get(light)
- if changed and last_service_data is not None:
- # It can happen that the state change events that are associated
- # with the last 'light.turn_on' call by this integration were not
- # final states. Possibly a later EVENT_STATE_CHANGED happened, where
- # the correct target brightness/color was reached.
- changed = compare_to(old_attributes=last_service_data)
- if not changed:
+ # Update state and check for a manual change not done in HA.
+ # Ensure HASS is correctly updating your light's state with
+ # light.turn_on calls if any problems arise. This
+ # can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
+ if switch._detect_non_ha_changes:
+ _LOGGER.debug(
+ "%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
+ " and check if it's last adapt succeeded.",
+ switch._name,
+ light,
+ )
+ # This update_entity probably isn't necessary now that we're checking
+ # if transitions finished from our last adapt.
+ await self.hass.helpers.entity_component.async_update_entity(light)
+ refreshed_state = self.hass.states.get(light)
+ _LOGGER.debug(
+ "%s: Current state of %s: %s",
+ switch._name,
+ light,
+ refreshed_state,
+ )
+ changed = compare_to(
+ old_attributes=last_service_data,
+ new_attributes=refreshed_state.attributes,
+ )
+ if changed:
_LOGGER.debug(
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
light,
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)
_fire_manual_control_event(switch, light, context, is_async=False)
- else:
- if n_changes > 1:
- _LOGGER.debug(
- "State of '%s' had 'cnt_significant_changes=%s' but the state"
- " changed to the expected settings now",
- light,
- n_changes,
- )
- self.cnt_significant_changes[light] = 0
-
- return changed
+ return True
+ _LOGGER.debug(
+ "%s: Light '%s' correctly matches our last adapt's service data, continuing..."
+ " context.id=%s.",
+ switch._name,
+ light,
+ context.id,
+ )
+ return False
async def maybe_cancel_adjusting(
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
diff --git a/tests/README.md b/tests/README.md
index adedcc4c..a576d518 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -5,10 +5,18 @@ Alternatively, you can use the provided Docker image to run the tests locally.
To run the tests using the Docker image, navigate to the `adaptive-lighting` repo folder and execute the following command:
+Linux or MacOS:
+
```bash
docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest
```
+Windows:
+
+```bash
+docker run -v %cd%:/app basnijholt/adaptive-lighting:latest
+```
+
This command will download the Docker image from [the adaptive-lighting Docker Hub repo](https://hub.docker.com/r/basnijholt/adaptive-lighting) and run the tests.
If you prefer to build the image yourself, use the following command:
diff --git a/tests/test_switch.py b/tests/test_switch.py
index a658a10b..197b6e75 100644
--- a/tests/test_switch.py
+++ b/tests/test_switch.py
@@ -56,6 +56,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
+ ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS,
CONF_NAME,
CONF_PLATFORM,
@@ -769,25 +770,63 @@ async def test_significant_change(hass):
)
await hass.async_block_till_done()
- switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass)
+ 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()
+
+ switch, _ = 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 update(force=True) # removes manual control
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
- # Change brightness by setting state (not using 'light.turn_on')
- attributes = hass.states.get(ENTITY_LIGHT).attributes
- new_attributes = attributes.copy()
- new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255
- new_attributes[ATTR_BRIGHTNESS] = new_brightness
- bed_light_instance._brightness = new_brightness
+ # Assert last_service_data got filled from update()
+ await update(force=True)
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):
+
+ # Simulate a transition to 255 where the update() is already using brightness 255.
+ await set_brightness(240)
+ 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)
+
+ # mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity
+ # Otherwise what happens is update_entity() refreshes the state to the last call of
+ # light.turn_on(). This is because we are not using hass.states.async_set() to
+ # set the brightness of the light. We mock `async_update_ha_state` because
+ # `async_update_entity` calls it.
+ with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"):
+ # On next update ENTITY_LIGHT should be marked as manually controlled
await update(force=False)
- assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
- # On next update the light should be marked as manually controlled
- await update(force=False)
- # TODO: the state should be `bool(manual_control) is True`
- assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
+ assert (
+ switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
+ )
+ assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
def test_color_difference_redmean():