diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
index c5bcb3a4..385259a6 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.md
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -1,17 +1,63 @@
---
-name: 'Bug Report'
-about: 'Report a bug in adaptive-lighting.'
-labels: kind/bug, need/triage
+name: Bug Report
+about: Report a bug in adaptive-lighting.
+title: ''
+labels: kind/bug, kind/feature, need/triage
+assignees: ''
+
---
-#### Version information:
+# Home Assistant Adaptive Lighting Issue Template
+
+## Bug Reports
+
+If you need help with using or configuring Adaptive Lighting, please [open a Q&A discussion thread here](https://github.com/basnijholt/adaptive-lighting/discussions/new?category=q-a) instead.
+
+### Before submitting a bug report, please follow these troubleshooting steps:
+
+Please confirm that you have completed the following steps:
+
+- [ ] I have updated to the [latest Adaptive Lighting version](https://github.com/basnijholt/adaptive-lighting/releases) available in [HACS](https://hacs.xyz/).
+- [ ] I have reviewed the [Troubleshooting Section](https://github.com/basnijholt/adaptive-lighting#troubleshooting) in the [README](https://github.com/basnijholt/adaptive-lighting#readme).
+- [ ] (If using Zigbee2MQTT) I have read the [Zigbee2MQTT troubleshooting guide](https://github.com/basnijholt/adaptive-lighting#zigbee2mqtt) in the [README](https://github.com/basnijholt/adaptive-lighting#readme).
+- [ ] I have checked the [V2 Roadmap](https://github.com/basnijholt/adaptive-lighting/discussions/291) and [open issues](https://github.com/basnijholt/adaptive-lighting/issues) to ensure my issue isn't a duplicate.
-#### Description:
-
+Please include the following information in your issue.
+
+*Issues missing this information may not be addressed.*
+
+1. **Debug logs** captured while the issue occurred. [See here for instructions on enabling debug logging](https://github.com/basnijholt/adaptive-lighting#troubleshooting):
+
+```
+
+```
+
+2. [Your Adaptive Lighting configuration](https://github.com/basnijholt/adaptive-lighting#gear-configuration):
+
+```
+
+```
+
+3. (If using Zigbee2MQTT), provide your configuration files (**remove all personal information before posting**):
+ - `devices.yaml`
+ - `groups.yaml`
+ - `configuration.yaml` ⚠️; **Warning** _**REMOVE ALL of the PERSONAL INFORMATION BELOW before posting**_ ⚠️;
+ - mqtt: `server`:
+ - mqtt: `user`:
+ - mqtt: `password`:
+ - advanced: `pan_id`:
+ - advanced: `network_key`:
+ - anything in `log_syslog` if you use this
+ - Brand and model number of problematic light(s)
+```
+
+```
+
+4. Describe the bug and how to reproduce it:
+
+
+
+5. Steps to reproduce the behavior:
diff --git a/.github/ISSUE_TEMPLATE/doc.md b/.github/ISSUE_TEMPLATE/doc.md
index 98c9a008..f6458d77 100644
--- a/.github/ISSUE_TEMPLATE/doc.md
+++ b/.github/ISSUE_TEMPLATE/doc.md
@@ -1,7 +1,10 @@
---
-name: 'Documentation Issue'
-about: 'Report missing, erroneous docs, broken links or propose new docs'
+name: Documentation Issue
+about: Report missing, erroneous docs, broken links or propose new docs
+title: ''
labels: kind/docs_issue, need/triage
+assignees: ''
+
---
#### Location
diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md
index cc515a20..d25a9689 100644
--- a/.github/ISSUE_TEMPLATE/enhancement.md
+++ b/.github/ISSUE_TEMPLATE/enhancement.md
@@ -1,5 +1,8 @@
---
-name: 'Enhancement'
-about: 'Suggest an improvement to an existing feature.'
+name: Enhancement
+about: Suggest an improvement to an existing feature.
+title: ''
labels: kind/enhancement, need/triage
+assignees: ''
+
---
diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md
index c4b787df..088b35e4 100644
--- a/.github/ISSUE_TEMPLATE/feature.md
+++ b/.github/ISSUE_TEMPLATE/feature.md
@@ -1,5 +1,8 @@
---
-name: 'Feature'
-about: 'Suggest a new feature'
+name: Feature
+about: Suggest a new feature
+title: ''
labels: kind/feature, need/triage
+assignees: ''
+
---
diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml
index 75820072..df0bee8a 100644
--- a/.github/workflows/install_dependencies/action.yml
+++ b/.github/workflows/install_dependencies/action.yml
@@ -1,10 +1,14 @@
name: 'Install Dependencies'
description: 'Install Home Assistant and test dependencies'
inputs:
- python_version:
+ python-version:
description: 'Python version'
required: true
default: '3.10'
+ core-version:
+ description: 'Home Assistant core version'
+ required: false
+ default: 'dev'
runs:
using: "composite"
@@ -21,11 +25,12 @@ runs:
with:
repository: home-assistant/core
path: core
- - name: Set up Python ${{ inputs.python_version }}
+ ref: ${{ inputs.core-version }}
+ - name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@v4.1.0
with:
- python-version: ${{ inputs.python_version }}
+ python-version: ${{ inputs.python-version }}
- name: Install dependencies
shell: bash
run: |
@@ -33,4 +38,5 @@ runs:
pip install -r core/requirements.txt --use-pep517
pip install -r core/requirements_test.txt --use-pep517
pip install -e core/ --use-pep517
+ pip install ulid-transform # this is in Adaptive-lighting's manifest.json
pip install $(python test_dependencies.py) --use-pep517
diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml
index 02bf93e3..cea75602 100644
--- a/.github/workflows/pytest.yaml
+++ b/.github/workflows/pytest.yaml
@@ -11,8 +11,10 @@ jobs:
runs-on: ubuntu-20.04
timeout-minutes: 60
strategy:
+ fail-fast: false
matrix:
python-version: ["3.10"]
+ core-version: ["2023.2.5", "2023.3.6", "2023.4.0", "dev"]
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3
@@ -20,7 +22,8 @@ jobs:
- name: Install Home Assistant
uses: ./.github/workflows/install_dependencies
with:
- python_version: ${{ matrix.python-version }}
+ python-version: ${{ matrix.python-version }}
+ core-version: ${{ matrix.core-version }}
- name: Click here for troubleshooting steps if tests break again.
run: |
diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml
index bec8cb4c..2c2754fb 100644
--- a/.github/workflows/update-readme.yml
+++ b/.github/workflows/update-readme.yml
@@ -20,11 +20,11 @@ jobs:
- name: Install Home Assistant
uses: ./.github/workflows/install_dependencies
with:
- python_version: "3.10"
+ python-version: "3.10"
- name: Install markdown-code-runner and README code dependencies
run: |
- pip install markdown-code-runner pandas tabulate
+ pip install markdown-code-runner==1.0.0 pandas tabulate
- name: Link custom_components/adaptive_lighting
run: |
diff --git a/README.md b/README.md
index f013d6c5..9ba7cc03 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,9 @@

-Adaptive Lighting is a custom component for Home Assistant that intelligently adjusts the brightness and color of your lights 💡 based on the sun's position, while still allowing for manual control. Try it out now by finding it in HACS (Home Assistant Community Store) and installing it!
+Adaptive Lighting is a custom component for [Home Assistant](https://www.home-assistant.io/) that intelligently adjusts the brightness and color of your lights 💡 based on the sun's position, while still allowing for manual control.
+
+Download and install directly through [HACS (Home Assistant Community Store)](https://hacs.xyz/)
By automatically adapting the settings of your lights throughout the day, Adaptive Lighting helps maintain your natural circadian rhythm 😴, which can lead to improved sleep, mood, and overall well-being. Experience cooler color temperatures at noon, gradually transitioning to warmer colors at sunset and sunrise.
@@ -58,7 +60,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as
- [:sunny: Sun Position](#sunny-sun-position)
- [:thermometer: Color Temperature](#thermometer-color-temperature)
- [:high_brightness: Brightness](#high_brightness-brightness)
- - [While using `adapt_until_sleep: true`](#while-using-adapt_until_sleep-true)
+ - [While using `transition_until_sleep: true`](#while-using-transition_until_sleep-true)
- [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors)
@@ -379,7 +381,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting
#### :high_brightness: Brightness

-#### While using `adapt_until_sleep: true`
+#### While using `transition_until_sleep: true`

@@ -421,7 +423,6 @@ These graphs were generated using the values calculated by the Adaptive Lighting
 Hudson Brendon 🌍 |
 Gabriel Visser 📖 |
 Gleb 🌍 |
-  Deleted user 🌍 |
 Avi Miller 📖 💻 |
 Denys Dovhan 🌍 |
 David Stenbeck 📖 |
diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json
index 33564f23..92b27dfa 100644
--- a/custom_components/adaptive_lighting/manifest.json
+++ b/custom_components/adaptive_lighting/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
"iot_class": "calculated",
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
- "requirements": [],
- "version": "1.10.0"
+ "requirements": ["ulid-transform"],
+ "version": "1.10.1"
}
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index 4817f984..5b42ee67 100644
--- a/custom_components/adaptive_lighting/switch.py
+++ b/custom_components/adaptive_lighting/switch.py
@@ -85,8 +85,10 @@ from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_to_rgb,
color_xy_to_hs,
+ color_xy_to_RGB,
)
import homeassistant.util.dt as dt_util
+import ulid_transform
import voluptuous as vol
from .const import (
@@ -181,21 +183,58 @@ BRIGHTNESS_ATTRS = {
}
# Keep a short domain version for the context instances (which can only be 36 chars)
-_DOMAIN_SHORT = "adapt_lgt"
+_DOMAIN_SHORT = "al"
-def _int_to_bytes(i: int, signed: bool = False) -> bytes:
- bits = i.bit_length()
- if signed:
- # Make room for the sign bit.
- bits += 1
- return i.to_bytes((bits + 7) // 8, "little", signed=signed)
+def _int_to_base36(num: int) -> str:
+ """
+ Convert an integer to its base-36 representation using numbers and uppercase letters.
+
+ Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
+ alphanumeric representation. The function takes an integer `num` as input and returns
+ its base-36 representation as a string.
+
+ Parameters
+ ----------
+ num
+ The integer to convert to base-36.
+
+ Returns
+ -------
+ str
+ The base-36 representation of the input integer.
+
+ Examples
+ --------
+ >>> num = 123456
+ >>> base36_num = int_to_base36(num)
+ >>> print(base36_num)
+ '2N9'
+ """
+ ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+ if num == 0:
+ return ALPHANUMERIC_CHARS[0]
+
+ base36_str = ""
+ base = len(ALPHANUMERIC_CHARS)
+
+ while num:
+ num, remainder = divmod(num, base)
+ base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str
+
+ return base36_str
def _short_hash(string: str, length: int = 4) -> str:
"""Create a hash of 'string' with length 'length'."""
- str_hash_bytes = _int_to_bytes(hash(string), signed=True)
- return base64.b85encode(str_hash_bytes)[:length]
+ return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
+
+
+def _remove_vowels(input_str: str, length: int = 4) -> str:
+ vowels = "aeiouAEIOU"
+ output_str = "".join([char for char in input_str if char not in vowels])
+ return output_str.zfill(length)[:length]
def create_context(
@@ -203,12 +242,16 @@ def create_context(
) -> Context:
"""Create a context that can identify this integration."""
# Use a hash for the name because otherwise the context might become
- # too long (max len == 36) to fit in the database.
- name_hash = _short_hash(name)
+ # too long (max len == 26) to fit in the database.
# Pack index with base85 to maximize the number of contexts we can create
- # before we exceed the 36-character limit and are forced to wrap.
- index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
- context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
+ # before we exceed the 26-character limit and are forced to wrap.
+ time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
+ name_hash = _short_hash(name)
+ which_short = _remove_vowels(which)
+ context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:"
+ chars_left = 26 - len(context_id_start)
+ index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:]
+ context_id = context_id_start + index_packed
parent_id = parent.id if parent else None
return Context(id=context_id, parent_id=parent_id)
@@ -217,7 +260,7 @@ def is_our_context(context: Context | None) -> bool:
"""Check whether this integration created 'context'."""
if context is None:
return False
- return context.id.startswith(_DOMAIN_SHORT)
+ return f":{_DOMAIN_SHORT}:" in context.id
def _split_service_data(service_data, adapt_brightness, adapt_color):
@@ -628,6 +671,41 @@ def color_difference_redmean(
return math.sqrt(red_term + green_term + blue_term)
+# All comparisons should be done with RGB since
+# converting anything to color temp is inaccurate.
+def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
+ if ATTR_RGB_COLOR in attributes:
+ return attributes
+
+ rgb = None
+ if ATTR_COLOR_TEMP_KELVIN in attributes:
+ rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN])
+ elif ATTR_XY_COLOR in attributes:
+ rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR])
+
+ if rgb is not None:
+ attributes[ATTR_RGB_COLOR] = rgb
+ _LOGGER.debug(f"Converted {attributes} to rgb {rgb}")
+ else:
+ _LOGGER.debug("No suitable conversion found")
+
+ return attributes
+
+
+def _add_missing_attributes(
+ old_attributes: dict[str, Any],
+ new_attributes: dict[str, Any],
+) -> dict[str, Any]:
+ if not any(
+ attr in old_attributes and attr in new_attributes
+ for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR]
+ ):
+ old_attributes = _convert_attributes(old_attributes)
+ new_attributes = _convert_attributes(new_attributes)
+
+ return old_attributes, new_attributes
+
+
def _attributes_have_changed(
light: str,
old_attributes: dict[str, Any],
@@ -636,6 +714,11 @@ def _attributes_have_changed(
adapt_color: bool,
context: Context,
) -> bool:
+ if adapt_color:
+ old_attributes, new_attributes = _add_missing_attributes(
+ old_attributes, new_attributes
+ )
+
if (
adapt_brightness
and ATTR_BRIGHTNESS in old_attributes
@@ -690,21 +773,6 @@ def _attributes_have_changed(
context.id,
)
return True
-
- switched_color_temp = (
- ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes
- )
- switched_to_rgb_color = (
- ATTR_COLOR_TEMP_KELVIN in old_attributes
- and ATTR_COLOR_TEMP_KELVIN not in new_attributes
- )
- if switched_color_temp or switched_to_rgb_color:
- # Light switched from RGB mode to color_temp or visa versa
- _LOGGER.debug(
- "'%s' switched from RGB mode to color_temp or visa versa",
- light,
- )
- return True
return False
@@ -1064,7 +1132,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
# See #80. Doesn't check if transitions differ but it does the job.
last_service_data = self.turn_on_off_listener.last_service_data
- if last_service_data.get(light) == service_data:
+ if not force and last_service_data.get(light) == service_data:
_LOGGER.debug(
"%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')",
self._name,
@@ -1135,10 +1203,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return
for light in lights:
# Don't adapt lights that haven't finished prior transitions.
- if not self.turn_on_off_listener.transition_timers.get(light):
+ timer = self.turn_on_off_listener.transition_timers.get(light)
+ if timer is not None and timer.is_running():
+ _LOGGER.debug(
+ "%s: Light '%s' is still transitioning",
+ self._name,
+ light,
+ )
+ else:
filtered_lights.append(light)
else:
filtered_lights = lights
+
if not filtered_lights:
return
@@ -1609,33 +1685,28 @@ class TurnOnOffListener:
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]
- ):
+ last_service_data = self.last_service_data.get(light)
+ if not last_service_data:
+ _LOGGER.debug("This should not ever happen. Please report to the devs.")
return
-
- delay = last_service_data[light][ATTR_TRANSITION]
+ last_transition = last_service_data.get(ATTR_TRANSITION)
+ if not last_transition:
+ _LOGGER.debug(
+ "No transition in last adapt for light %s, continuing...", light
+ )
+ return
+ _LOGGER.debug(
+ "Start transition timer of %s seconds for light %s", last_transition, light
+ )
async def reset():
+ ValueError("TEST")
_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)
+ self._handle_timer(light, self.transition_timers, last_transition, reset)
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
"""Set the time after which the lights are automatically reset."""
@@ -1758,7 +1829,7 @@ class TurnOnOffListener:
async def state_changed_event_listener(self, event: Event) -> None:
"""Track 'state_changed' events."""
entity_id = event.data.get(ATTR_ENTITY_ID, "")
- if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN:
+ if entity_id not in self.lights:
return
new_state = event.data.get("new_state")
@@ -1803,6 +1874,10 @@ class TurnOnOffListener:
entity_id,
)
self.last_state_change[entity_id] = [new_state]
+ _LOGGER.debug(
+ "Last transition: %s",
+ self.last_service_data[entity_id].get(ATTR_TRANSITION),
+ )
self.start_transition_timer(entity_id)
elif old_state is not None:
self.last_state_change[entity_id].append(new_state)
diff --git a/tests/README.md b/tests/README.md
index a576d518..1c472541 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -5,17 +5,15 @@ 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:
-
+Linux / MacOS / Windows PowerShell:
```bash
-docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest
+docker run -v ${PWD}:/app basnijholt/adaptive-lighting:latest
```
-Windows:
-
-```bash
-docker run -v %cd%:/app basnijholt/adaptive-lighting:latest
-```
+- In windows command prompt, the command is:
+ ```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.
diff --git a/tests/test_switch.py b/tests/test_switch.py
index dc4eed94..e750d206 100644
--- a/tests/test_switch.py
+++ b/tests/test_switch.py
@@ -1,6 +1,7 @@
"""Tests for Adaptive Lighting switches."""
# pylint: disable=protected-access
import asyncio
+from copy import deepcopy
import datetime
import logging
from random import randint
@@ -48,6 +49,8 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
+ ATTR_TRANSITION,
+ ATTR_XY_COLOR,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.light import SERVICE_TURN_OFF
@@ -61,6 +64,7 @@ from homeassistant.const import (
CONF_LIGHTS,
CONF_NAME,
CONF_PLATFORM,
+ EVENT_STATE_CHANGED,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
@@ -71,6 +75,7 @@ from homeassistant.setup import async_setup_component
from homeassistant.util.color import color_temperature_mired_to_kelvin
import homeassistant.util.dt as dt_util
import pytest
+import ulid_transform
import voluptuous.error
from tests.common import MockConfigEntry, mock_area_registry
@@ -107,6 +112,15 @@ ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_adapt_color_{DEFAULT_NAME}"
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
+GLOBAL_TEST_DEPENDENCIES = [
+ "test_adaptive_lighting_switches",
+ "test_light_settings",
+]
+
+
+def create_random_context() -> str:
+ return Context(id=ulid_transform.ulid_now(), parent_id=None)
+
@pytest.fixture
def reset_time_zone():
@@ -210,6 +224,59 @@ async def setup_lights_and_switch(hass, extra_conf=None):
return switch, lights_instances
+# see https://github.com/home-assistant/core/blob/dev/homeassistant/scripts/benchmark/__init__.py
+# basically just search the repo for EVENT_STATE_CHANGED look for how it's fired.
+def create_transition_events(
+ light: str,
+ state: State,
+ last: dict | None = None,
+ current: dict | None = None,
+ total_events: int = 4,
+) -> list[dict]:
+ assert light is not None
+ all_events = []
+ for i in range(1, total_events):
+ # Build basic event data.
+ attributes = {}
+
+ # The first state change always has the context from our integration.
+ # That one will not be in all_events.
+ # It's very possible it stores the parent_id though.
+ # If it stores the parent_id in all situations, there's a great improvement
+ # that could added in future updates.
+
+ # Simulate the events the bulb would send to HASS.
+ last_brightness = last.get(ATTR_BRIGHTNESS) or state[ATTR_BRIGHTNESS]
+ current_brightness = current.get(ATTR_BRIGHTNESS)
+ if (
+ last_brightness
+ and current_brightness
+ and last_brightness != current_brightness
+ ):
+ diff = (current_brightness - last_brightness) * (i / total_events)
+ attributes[ATTR_BRIGHTNESS] = last_brightness + diff
+ elif current_brightness:
+ attributes[ATTR_BRIGHTNESS] = current_brightness
+ current_kelvin = current.get(ATTR_COLOR_TEMP_KELVIN)
+ last_kelvin = last.get(ATTR_COLOR_TEMP_KELVIN) or state[ATTR_COLOR_TEMP_KELVIN]
+ if last_kelvin and current_kelvin and last_kelvin != current_kelvin:
+ diff = (current_kelvin - last_kelvin) * (i / total_events)
+ attributes[ATTR_COLOR_TEMP_KELVIN] = last_kelvin + diff
+ elif current_kelvin:
+ attributes[ATTR_COLOR_TEMP_KELVIN] = current_kelvin
+
+ # Pack event
+ event_data = {
+ ATTR_ENTITY_ID: light,
+ "old_state": State(light, "on", attributes=last),
+ "new_state": State(
+ light, "on", attributes=attributes, context=create_random_context()
+ ),
+ }
+ all_events.append(event_data)
+ return all_events
+
+
async def test_adaptive_lighting_switches(hass):
"""Test switches created for adaptive_lighting integration."""
entry, _ = await setup_switch(hass, {})
@@ -237,6 +304,7 @@ async def test_adaptive_lighting_switches(hass):
@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS)
+@pytest.mark.dependency("test_adaptive_lighting_switches")
async def test_adaptive_lighting_time_zones_with_default_settings(
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
):
@@ -429,6 +497,7 @@ async def test_light_settings(hass):
assert_expected_color_temp(state)
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_turn_on_off_listener_not_tracking_untracked_lights(hass):
"""Test that lights that are not in a Adaptive Lighting switch aren't tracked."""
switch, _ = await setup_lights_and_switch(hass)
@@ -448,6 +517,7 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass):
assert light not in switch.turn_on_off_listener.lights
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_manual_control(hass):
"""Test the 'manual control' tracking."""
switch, (light, *_) = await setup_lights_and_switch(hass)
@@ -595,6 +665,7 @@ async def test_manual_control(hass):
assert all([not manual_control[eid] for eid in switch._lights])
+@pytest.mark.dependency(depends=[*GLOBAL_TEST_DEPENDENCIES, "test_manual_control"])
async def test_auto_reset_manual_control(hass):
switch, (light, *_) = await setup_lights_and_switch(
hass, {CONF_AUTORESET_CONTROL: 0.2}
@@ -639,6 +710,7 @@ async def test_auto_reset_manual_control(hass):
assert not manual_control[light.entity_id]
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_apply_service(hass):
"""Test adaptive_lighting.apply service."""
switch, (_, _, light) = await setup_lights_and_switch(hass)
@@ -702,6 +774,9 @@ async def test_apply_service(hass):
assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN]
+@pytest.mark.dependency(
+ depends=[*GLOBAL_TEST_DEPENDENCIES, "test_apply_service", "test_manual_control"]
+)
async def test_switch_off_on_off(hass):
"""Test switch rapid off_on_off."""
@@ -752,85 +827,7 @@ async def test_switch_off_on_off(hass):
assert state == STATE_OFF
-async def test_significant_change(hass):
- """Test significant change."""
-
- async def turn_light(state, **kwargs):
- await hass.services.async_call(
- LIGHT_DOMAIN,
- SERVICE_TURN_ON if state else SERVICE_TURN_OFF,
- {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs},
- blocking=True,
- )
- await hass.async_block_till_done()
-
- async def update(force):
- await switch._update_attrs_and_maybe_adapt_lights(
- transition=0,
- context=switch.create_context("test"),
- force=force,
- )
- 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()
-
- 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)
- assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
-
- # 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
-
- # 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 (
- switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
- )
- assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
-
-
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
def test_color_difference_redmean():
"""Test color_difference_redmean function."""
for _ in range(10):
@@ -840,14 +837,6 @@ def test_color_difference_redmean():
color_difference_redmean((0, 0, 0), (255, 255, 255))
-def test_is_our_context():
- """Test is_our_context function."""
- context = create_context(DOMAIN, "test", 0)
- assert is_our_context(context)
- assert not is_our_context(None)
- assert not is_our_context(Context())
-
-
def test_attributes_have_changed():
"""Test _attributes_have_changed function."""
attributes_1 = {
@@ -875,12 +864,240 @@ def test_attributes_have_changed():
assert _attributes_have_changed(
old_attributes=attributes_1, new_attributes=attrs, **kwargs
)
- # Switch from rgb_color to color_temp
- assert _attributes_have_changed(
- old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 100},
- new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0)},
+ _LOGGER.debug("Test switch from color_temp to rgb_color")
+ assert not _attributes_have_changed(
+ old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702},
+ new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (255, 166, 87)},
**kwargs,
)
+ _LOGGER.debug("Test switch from rgb_color to color_temp")
+ assert not _attributes_have_changed(
+ old_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (255, 166, 87)},
+ new_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702},
+ **kwargs,
+ )
+ _LOGGER.debug("Test switch from color_temp to color_xy")
+ assert not _attributes_have_changed(
+ old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702},
+ new_attributes={ATTR_BRIGHTNESS: 1, ATTR_XY_COLOR: (0.526, 0.387)},
+ **kwargs,
+ )
+ _LOGGER.debug("Test switch from color_xy to color_temp")
+ assert not _attributes_have_changed(
+ old_attributes={ATTR_BRIGHTNESS: 1, ATTR_XY_COLOR: (0.526, 0.387)},
+ new_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702},
+ **kwargs,
+ )
+
+
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
+async def test_state_change_handlers(hass):
+ """
+ Test TurnOnOffListener's EVENT_STATE_CHANGED listener.
+ ======================
+ Sequence of events:
+ 1. Transition from sleep mode to normal.
+ 2. Create simulated transition events for that adapt.
+ 3. Fire all simulated transition events.
+ 4. Assert all possible problems that would result.
+ Also tests significant changes.
+ """
+ switch, (light, *_) = await setup_lights_and_switch(hass)
+ context = switch.create_context("test") # needs to be passed to update method
+
+ # [Config options]:
+ transition_used = 2
+ total_events = 5
+
+ async def set_brightness(val: int):
+ # 'Unsafe' set but we know what we're doing.
+ hass.states.async_set(
+ ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1}
+ )
+ await hass.async_block_till_done()
+ # Call code in TurnOnOffListener
+ hass.bus.async_fire(
+ EVENT_STATE_CHANGED,
+ {
+ "new_state": {
+ ATTR_ENTITY_ID: ENTITY_LIGHT,
+ "state": "on",
+ ATTR_BRIGHTNESS: val,
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ async def turn_light(state, **kwargs):
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON if state else SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ async def update(force: bool = False):
+ await switch._update_attrs_and_maybe_adapt_lights(
+ force=force, transition=0, context=context
+ )
+ await hass.async_block_till_done()
+
+ # 1. Adapt to sleep without a transition.
+ # Should only be one state change.
+ _LOGGER.debug('test_state_change_handling: Turn on "sleep mode"')
+ await hass.services.async_call(
+ SWITCH_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT)
+ assert len(switch.turn_on_off_listener.last_state_change[ENTITY_LIGHT]) == 1
+ assert not switch.turn_on_off_listener.transition_timers.get(ENTITY_LIGHT)
+ last_service_data = deepcopy(switch.turn_on_off_listener.last_service_data)
+ assert last_service_data.get(ENTITY_LIGHT)
+
+ # 2 Adapt from sleep with a 'transition'.
+ await switch.sleep_mode_switch.async_turn_off()
+ await switch._update_attrs_and_maybe_adapt_lights(
+ force=False, transition=0, context=context
+ )
+ await hass.async_block_till_done()
+ current_service_data = switch.turn_on_off_listener.last_service_data
+ assert current_service_data != last_service_data
+
+ for light in switch._lights:
+ # current_service_data should have changed after the last update.
+ assert current_service_data.get(light)
+ assert last_service_data.get(light)
+ assert current_service_data[light] != last_service_data[light]
+
+ # Test same context id events.
+ current_service_data[light][ATTR_TRANSITION] = transition_used
+ hass.bus.async_fire(
+ EVENT_STATE_CHANGED,
+ {
+ ATTR_ENTITY_ID: light,
+ "old_state": State(light, "on", attributes=last_service_data),
+ "new_state": State(
+ light, "on", attributes=current_service_data, context=context
+ ),
+ },
+ )
+ assert not switch.turn_on_off_listener.transition_timers.get(light)
+
+ # 2.3 Refire and overwrite the original state_changed event with our 'transition'
+ hass.bus.async_fire(
+ EVENT_STATE_CHANGED,
+ {
+ ATTR_ENTITY_ID: light,
+ "old_state": State(light, "on", attributes=last_service_data),
+ "new_state": State(
+ light,
+ "on",
+ attributes=current_service_data,
+ # We need to overwrite the old context_id
+ context=switch.create_context("test"),
+ ),
+ },
+ )
+ await hass.async_block_till_done()
+ # Assert our transition timer was created.
+ assert switch.turn_on_off_listener.transition_timers.get(light)
+ # 2.5 Simulate a transition. There's no other way to do this in the demo.
+ events = create_transition_events(
+ light=light,
+ state=hass.states.get(light),
+ last=last_service_data[light],
+ current=current_service_data[light],
+ total_events=total_events,
+ )
+ # 3. Fire simulated events for our TurnOnOffListener
+ for event in events:
+ _LOGGER.debug("Test EVENT_STATE_CHANGED listener")
+ hass.bus.async_fire(EVENT_STATE_CHANGED, event)
+ await hass.async_block_till_done()
+ # On real systems HA fires transition state changes every ~3 seconds.
+ # asyncio.sleep(3)
+ # 4. Assert the transition timer started and everything was filled.
+ listener = switch.turn_on_off_listener
+ assert listener.last_state_change.get(ENTITY_LIGHT)
+ assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events
+ assert listener.transition_timers.get(ENTITY_LIGHT)
+
+ # 5. Execute some checks during a transition
+ _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
+ await asyncio.sleep(transition_used / 3)
+ # Ensure the timer still exists
+ timer = listener.transition_timers.get(ENTITY_LIGHT)
+ assert timer and timer.is_running()
+ last_service_data = deepcopy(current_service_data)
+ await update()
+ assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
+ await update()
+ assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
+ timer = listener.transition_timers.get(ENTITY_LIGHT)
+ assert timer and timer.is_running()
+ # Ensure the light did not adapt during the transition.
+ assert last_service_data == current_service_data
+
+ # 6. Assert everything after the transition finishes.
+ await asyncio.sleep(transition_used)
+ assert listener.last_state_change.get(ENTITY_LIGHT)
+ assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events
+ # Timer should be done and reset now.
+ # This is the assert that I can't fix.
+ timer = listener.transition_timers.get(ENTITY_LIGHT)
+ assert not timer or not timer.is_running()
+
+ # 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)
+ assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
+
+ await turn_light(True, brightness=50)
+ _LOGGER.debug("Test: Brightness set to %s", 50)
+
+ # On next update ENTITY_LIGHT should be marked as manually controlled
+ await update(force=False)
+ assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
+ assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None
+ assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
+
+
+@pytest.mark.dependency(
+ depends=[
+ *GLOBAL_TEST_DEPENDENCIES,
+ "test_manual_control",
+ "test_apply_service",
+ "test_attributes_have_changed",
+ "test_state_change_handling",
+ ]
+)
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
+def test_is_our_context():
+ """Test is_our_context function."""
+ context = create_context(DOMAIN, "test", 0)
+ assert is_our_context(context)
+ assert not is_our_context(None)
+ assert not is_our_context(Context())
async def test_unload_switch(hass):
@@ -949,6 +1166,7 @@ async def test_turn_on_and_off_when_already_at_that_state(hass):
await hass.async_block_till_done()
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_async_update_at_interval(hass):
"""Test '_async_update_at_interval' method."""
_, switch = await setup_switch(hass, {})
@@ -956,6 +1174,7 @@ async def test_async_update_at_interval(hass):
@pytest.mark.parametrize("separate_turn_on_commands", (True, False))
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
"""Test 'separate_turn_on_commands' argument."""
switch, (light, *_) = await setup_lights_and_switch(
@@ -992,6 +1211,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
assert sleep_color_temp != color_temp
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_area(hass):
switch, (light, *_) = await setup_lights_and_switch(hass)
@@ -1028,6 +1248,7 @@ async def test_area(hass):
assert light.entity_id not in switch.turn_on_off_listener.last_service_data
+@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
async def test_change_switch_settings_service(hass):
"""Test adaptive_lighting.change_switch_settings service."""
switch, (_, _, light) = await setup_lights_and_switch(hass)