diff --git a/.all-contributorsrc b/.all-contributorsrc
index 344b3112..6c825dad 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -400,7 +400,9 @@
"avatar_url": "https://avatars.githubusercontent.com/u/2219836?v=4",
"profile": "https://github.com/th3w1zard1",
"contributions": [
- "code"
+ "code",
+ "bug",
+ "maintenance"
]
},
{
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 857be993..29d7799e 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)
@@ -380,7 +382,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`

@@ -422,7 +424,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 π |
@@ -446,7 +447,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/const.py b/custom_components/adaptive_lighting/const.py
index 51a9be76..0d7436cb 100644
--- a/custom_components/adaptive_lighting/const.py
+++ b/custom_components/adaptive_lighting/const.py
@@ -290,6 +290,8 @@ VALIDATION_TUPLES = [
),
]
+CONST_COLOR = "color"
+
def timedelta_as_int(value):
"""Convert a `datetime.timedelta` object to an integer.
diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json
index 33564f23..2dec4c83 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.11.0"
}
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index 0abedf26..4b4d88ec 100644
--- a/custom_components/adaptive_lighting/switch.py
+++ b/custom_components/adaptive_lighting/switch.py
@@ -23,7 +23,11 @@ from homeassistant.components.light import (
ATTR_COLOR_NAME,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
+ ATTR_MAX_COLOR_TEMP_KELVIN,
+ ATTR_MIN_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
+ ATTR_RGBW_COLOR,
+ ATTR_RGBWW_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
ATTR_XY_COLOR,
@@ -32,6 +36,7 @@ from homeassistant.components.light import (
COLOR_MODE_HS,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
+ COLOR_MODE_RGBWW,
COLOR_MODE_XY,
)
from homeassistant.components.light import (
@@ -85,8 +90,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 (
@@ -128,6 +135,7 @@ from .const import (
CONF_TRANSITION,
CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS,
+ CONST_COLOR,
DOMAIN,
EXTRA_VALIDATION,
ICON_BRIGHTNESS,
@@ -154,6 +162,16 @@ _SUPPORT_OPTS = {
"transition": SUPPORT_TRANSITION,
}
+VALID_COLOR_MODES = {
+ COLOR_MODE_BRIGHTNESS: ATTR_BRIGHTNESS,
+ COLOR_MODE_COLOR_TEMP: ATTR_COLOR_TEMP_KELVIN,
+ COLOR_MODE_HS: ATTR_HS_COLOR,
+ COLOR_MODE_RGB: ATTR_RGB_COLOR,
+ COLOR_MODE_RGBW: ATTR_RGBW_COLOR,
+ COLOR_MODE_RGBWW: ATTR_RGBWW_COLOR,
+ COLOR_MODE_XY: ATTR_XY_COLOR,
+}
+
_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT)
_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
@@ -182,21 +200,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(
@@ -204,12 +259,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)
@@ -218,7 +277,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):
@@ -382,6 +441,7 @@ def _fire_manual_control_event(
switch.entity_id,
light,
)
+ switch.turn_on_off_listener.mark_as_manual_control(light)
fire(
f"{DOMAIN}.manual_control",
{ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id},
@@ -477,7 +537,6 @@ async def async_setup_entry(
all_lights = _expand_light_groups(switch.hass, lights)
if service_call.data[CONF_MANUAL_CONTROL]:
for light in all_lights:
- switch.turn_on_off_listener.mark_as_manual_control(light)
_fire_manual_control_event(switch, light, service_call.context)
else:
switch.turn_on_off_listener.reset(*all_lights)
@@ -581,33 +640,51 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
return list(all_lights)
+def _supported_to_attributes(supported):
+ supported_attributes = {}
+ supports_colors = False
+ for mode, attr in VALID_COLOR_MODES.items():
+ if mode not in supported:
+ continue
+ supported_attributes[attr] = True
+ if (
+ not supports_colors
+ and mode != COLOR_MODE_BRIGHTNESS
+ and mode != COLOR_MODE_COLOR_TEMP
+ ):
+ supports_colors = True
+ return supported_attributes, supports_colors
+
+
def _supported_features(hass: HomeAssistant, light: str):
state = hass.states.get(light)
- supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
- supported = {
- key for key, value in _SUPPORT_OPTS.items() if supported_features & value
+ legacy_supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ legacy_supported = {
+ key for key, value in _SUPPORT_OPTS.items() if legacy_supported_features & value
}
supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set())
- if COLOR_MODE_RGB in supported_color_modes:
- supported.add("color")
+ supported, supports_colors = _supported_to_attributes(
+ legacy_supported.union(supported_color_modes)
+ )
+ min_kelvin = state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN)
+ max_kelvin = state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN)
+ supported.update(
+ {
+ ATTR_MIN_COLOR_TEMP_KELVIN: min_kelvin,
+ ATTR_MAX_COLOR_TEMP_KELVIN: max_kelvin,
+ }
+ )
+ if supports_colors:
# Adding brightness here, see
# comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011
- supported.add("brightness")
- if COLOR_MODE_RGBW in supported_color_modes:
- supported.add("color")
- supported.add("brightness") # see above url
- if COLOR_MODE_XY in supported_color_modes:
- supported.add("color")
- supported.add("brightness") # see above url
- if COLOR_MODE_HS in supported_color_modes:
- supported.add("color")
- supported.add("brightness") # see above url
- if COLOR_MODE_COLOR_TEMP in supported_color_modes:
- supported.add("color_temp")
- supported.add("brightness") # see above url
- if COLOR_MODE_BRIGHTNESS in supported_color_modes:
- supported.add("brightness")
- return supported
+ supported[ATTR_BRIGHTNESS] = True
+ if CONST_COLOR not in legacy_supported:
+ # supports_colors = False
+ _LOGGER.debug(
+ "'supported_color_modes' supports color but the legacy 'supported_features'"
+ " bitfield says we do not. Despite this we'll assume light '%s' supports colors",
+ )
+ return supported, supports_colors
def color_difference_redmean(
@@ -629,6 +706,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],
@@ -637,6 +749,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
@@ -691,21 +808,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
@@ -950,6 +1052,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if self.turn_on_off_listener.manual_control.get(light)
]
extra_state_attributes.update(self._settings)
+ timers = self.turn_on_off_listener.auto_reset_manual_control_timers
+ extra_state_attributes["autoreset_time_remaining"] = {
+ light: time
+ for light in self._lights
+ if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0
+ }
return extra_state_attributes
def create_context(
@@ -1018,9 +1126,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if lock is not None and lock.locked():
_LOGGER.debug("%s: '%s' is locked", self._name, light)
return
- service_data = {ATTR_ENTITY_ID: light}
- features = _supported_features(self.hass, light)
-
if transition is None:
transition = self._transition
if adapt_brightness is None:
@@ -1030,16 +1135,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if prefer_rgb_color is None:
prefer_rgb_color = self._prefer_rgb_color
- # Check transition == 0 to fix #378
- if "transition" in features and transition > 0:
- service_data[ATTR_TRANSITION] = transition
-
# The switch might be off and not have _settings set.
self._settings = self._sun_light_settings.get_settings(
self.sleep_mode_switch.is_on, transition
)
- if "brightness" in features and adapt_brightness:
+ # Build service data.
+ service_data = {ATTR_ENTITY_ID: light}
+ features, supports_colors = _supported_features(self.hass, light)
+
+ # Check transition == 0 to fix #378
+ if ATTR_TRANSITION in features and transition > 0:
+ service_data[ATTR_TRANSITION] = transition
+ if ATTR_BRIGHTNESS in features and adapt_brightness:
brightness = round(255 * self._settings["brightness_pct"] / 100)
service_data[ATTR_BRIGHTNESS] = brightness
@@ -1048,39 +1156,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
and self._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color"
)
if (
- "color_temp" in features
+ ATTR_COLOR_TEMP_KELVIN in features
and adapt_color
- and not (prefer_rgb_color and "color" in features)
- and not (sleep_rgb and "color" in features)
+ and not (prefer_rgb_color and supports_colors)
+ and not (sleep_rgb and supports_colors)
):
_LOGGER.debug("%s: Setting color_temp of light %s", self._name, light)
- attributes = self.hass.states.get(light).attributes
- min_kelvin = attributes["min_color_temp_kelvin"]
- max_kelvin = attributes["max_color_temp_kelvin"]
+ min_kelvin = features[ATTR_MIN_COLOR_TEMP_KELVIN]
+ max_kelvin = features[ATTR_MAX_COLOR_TEMP_KELVIN]
color_temp_kelvin = self._settings["color_temp_kelvin"]
color_temp_kelvin = max(min(color_temp_kelvin, max_kelvin), min_kelvin)
service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
- elif "color" in features and adapt_color:
+ elif supports_colors and adapt_color:
_LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light)
service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"]
context = context or self.create_context("adapt_lights")
- if (
- self._take_over_control
- and self._detect_non_ha_changes
- and not force
- and await self.turn_on_off_listener.significant_change(
- self,
- light,
- adapt_brightness,
- adapt_color,
- context,
- )
- ):
- return
+
# 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,
@@ -1145,21 +1240,32 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if lights is None:
lights = self._lights
- if not force and self._only_once:
- return
-
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 force:
+ if self._only_once:
+ return
+ for light in lights:
+ # Don't adapt lights that haven't finished prior transitions.
+ 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
- await self._adapt_lights(filtered_lights, transition, force, context)
+ await self._update_manual_control_and_maybe_adapt(
+ filtered_lights, transition, force, context
+ )
- async def _adapt_lights(
+ async def _update_manual_control_and_maybe_adapt(
self,
lights: list[str],
transition: int | None,
@@ -1168,34 +1274,53 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
) -> None:
assert context is not None
_LOGGER.debug(
- "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called",
+ "%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called",
self.name,
lights,
transition,
force,
context.id,
)
+
+ adapt_brightness = self.adapt_brightness_switch.is_on
+ adapt_color = self.adapt_color_switch.is_on
+
for light in lights:
if not is_on(self.hass, light):
continue
- if (
- self._take_over_control
- and self.turn_on_off_listener.is_manually_controlled(
+
+ manually_controlled = self.turn_on_off_listener.is_manually_controlled(
+ self,
+ light,
+ force,
+ adapt_brightness,
+ adapt_color,
+ )
+
+ significant_change = (
+ self._detect_non_ha_changes
+ and not force
+ and await self.turn_on_off_listener.significant_change(
self,
light,
- force,
- self.adapt_brightness_switch.is_on,
- self.adapt_color_switch.is_on,
+ adapt_brightness,
+ adapt_color,
+ context,
)
- ):
- _LOGGER.debug(
- "%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
- self._name,
- light,
- context.id,
- )
- continue
- await self._adapt_light(light, transition, force=force, context=context)
+ )
+
+ if self._take_over_control and (manually_controlled or significant_change):
+ if manually_controlled:
+ _LOGGER.debug(
+ "%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
+ self._name,
+ light,
+ context.id,
+ )
+ else:
+ _fire_manual_control_event(self, light, context)
+ else:
+ await self._adapt_light(light, transition, force=force, context=context)
async def _sleep_mode_switch_state_event(self, event: Event) -> None:
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
@@ -1598,33 +1723,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."""
@@ -1778,7 +1898,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")
@@ -1823,6 +1943,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)
@@ -1853,7 +1977,7 @@ class TurnOnOffListener:
):
# Light was already on and 'light.turn_on' was not called by
# the adaptive_lighting integration.
- manual_control = self.mark_as_manual_control(light)
+ manual_control = True
_fire_manual_control_event(switch, light, turn_on_event.context)
_LOGGER.debug(
"'%s' was already on and 'light.turn_on' was not called by the"
@@ -1921,8 +2045,6 @@ class TurnOnOffListener:
light,
context.id,
)
- self.mark_as_manual_control(light)
- _fire_manual_control_event(switch, light, context, is_async=False)
return True
_LOGGER.debug(
"%s: Light '%s' correctly matches our last adapt's service data, continuing..."
@@ -2065,3 +2187,10 @@ class _AsyncSingleShotTimer:
if self.task:
self.task.cancel()
self.callback = None
+
+ def remaining_time(self):
+ """Return the remaining time before the timer expires."""
+ if self.start_time is not None:
+ elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds()
+ return max(0, self.delay - elapsed_time)
+ return 0
diff --git a/tests/README.md b/tests/README.md
index adedcc4c..1c472541 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -5,10 +5,16 @@ 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 / MacOS / Windows PowerShell:
```bash
-docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest
+docker run -v ${PWD}:/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.
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 9e88b332..476e83cc 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
@@ -47,6 +48,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
@@ -60,6 +63,7 @@ from homeassistant.const import (
CONF_LIGHTS,
CONF_NAME,
CONF_PLATFORM,
+ EVENT_STATE_CHANGED,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
@@ -70,6 +74,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
@@ -106,6 +111,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():
@@ -208,6 +222,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, {})
@@ -235,6 +302,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
):
@@ -427,6 +495,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)
@@ -446,6 +515,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)
@@ -593,6 +663,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.1}
@@ -621,9 +692,15 @@ async def test_auto_reset_manual_control(hass):
await turn_light(True, brightness=1)
await turn_light(True, brightness=10)
assert manual_control[light.entity_id]
+ assert (
+ switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0
+ )
await asyncio.sleep(0.3) # Should be enough time for auto reset
await update()
assert not manual_control[light.entity_id], (light, manual_control)
+ assert (
+ light.entity_id not in switch.extra_state_attributes["autoreset_time_remaining"]
+ )
# Do a couple of quick changes and check that light is not reset
for i in range(3):
@@ -637,6 +714,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)
@@ -700,6 +778,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."""
@@ -750,85 +831,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):
@@ -838,14 +841,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 = {
@@ -873,12 +868,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):
@@ -947,6 +1170,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, {})
@@ -954,6 +1178,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(
@@ -990,6 +1215,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)
@@ -1026,6 +1252,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)