mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-13 23:34:04 +02:00
merge
This commit is contained in:
commit
8fa554a4e7
12 changed files with 537 additions and 177 deletions
68
.github/ISSUE_TEMPLATE/bug-report.md
vendored
68
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
|
@ -1,17 +1,63 @@
|
||||||
---
|
---
|
||||||
name: 'Bug Report'
|
name: Bug Report
|
||||||
about: 'Report a bug in adaptive-lighting.'
|
about: Report a bug in adaptive-lighting.
|
||||||
labels: kind/bug, need/triage
|
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:
|
### Required information for bug reports:
|
||||||
<!-- This is where you get to tell us what went wrong. When doing so, please make sure to include *all* relevant information.
|
|
||||||
|
|
||||||
Please try to include:
|
Please include the following information in your issue.
|
||||||
* What you were doing when you experienced the bug.
|
|
||||||
* Any error messages you saw, *where* you saw them, and what you believe may have caused them (if you have any ideas).
|
*Issues missing this information may not be addressed.*
|
||||||
* When possible, steps to reliably produce the bug.
|
|
||||||
-->
|
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:
|
||||||
|
|
|
||||||
7
.github/ISSUE_TEMPLATE/doc.md
vendored
7
.github/ISSUE_TEMPLATE/doc.md
vendored
|
|
@ -1,7 +1,10 @@
|
||||||
---
|
---
|
||||||
name: 'Documentation Issue'
|
name: Documentation Issue
|
||||||
about: 'Report missing, erroneous docs, broken links or propose new docs'
|
about: Report missing, erroneous docs, broken links or propose new docs
|
||||||
|
title: ''
|
||||||
labels: kind/docs_issue, need/triage
|
labels: kind/docs_issue, need/triage
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Location
|
#### Location
|
||||||
|
|
|
||||||
7
.github/ISSUE_TEMPLATE/enhancement.md
vendored
7
.github/ISSUE_TEMPLATE/enhancement.md
vendored
|
|
@ -1,5 +1,8 @@
|
||||||
---
|
---
|
||||||
name: 'Enhancement'
|
name: Enhancement
|
||||||
about: 'Suggest an improvement to an existing feature.'
|
about: Suggest an improvement to an existing feature.
|
||||||
|
title: ''
|
||||||
labels: kind/enhancement, need/triage
|
labels: kind/enhancement, need/triage
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
7
.github/ISSUE_TEMPLATE/feature.md
vendored
7
.github/ISSUE_TEMPLATE/feature.md
vendored
|
|
@ -1,5 +1,8 @@
|
||||||
---
|
---
|
||||||
name: 'Feature'
|
name: Feature
|
||||||
about: 'Suggest a new feature'
|
about: Suggest a new feature
|
||||||
|
title: ''
|
||||||
labels: kind/feature, need/triage
|
labels: kind/feature, need/triage
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
name: 'Install Dependencies'
|
name: 'Install Dependencies'
|
||||||
description: 'Install Home Assistant and test dependencies'
|
description: 'Install Home Assistant and test dependencies'
|
||||||
inputs:
|
inputs:
|
||||||
python_version:
|
python-version:
|
||||||
description: 'Python version'
|
description: 'Python version'
|
||||||
required: true
|
required: true
|
||||||
default: '3.10'
|
default: '3.10'
|
||||||
|
core-version:
|
||||||
|
description: 'Home Assistant core version'
|
||||||
|
required: false
|
||||||
|
default: 'dev'
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
|
|
@ -21,11 +25,12 @@ runs:
|
||||||
with:
|
with:
|
||||||
repository: home-assistant/core
|
repository: home-assistant/core
|
||||||
path: core
|
path: core
|
||||||
- name: Set up Python ${{ inputs.python_version }}
|
ref: ${{ inputs.core-version }}
|
||||||
|
- name: Set up Python ${{ inputs.python-version }}
|
||||||
id: python
|
id: python
|
||||||
uses: actions/setup-python@v4.1.0
|
uses: actions/setup-python@v4.1.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ inputs.python_version }}
|
python-version: ${{ inputs.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -33,5 +38,6 @@ runs:
|
||||||
pip install -r core/requirements.txt --use-pep517
|
pip install -r core/requirements.txt --use-pep517
|
||||||
pip install -r core/requirements_test.txt --use-pep517
|
pip install -r core/requirements_test.txt --use-pep517
|
||||||
pip install -e core/ --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
|
pip install $(python test_dependencies.py) --use-pep517
|
||||||
pip install jaraco --use-pep517
|
pip install jaraco --use-pep517
|
||||||
|
|
|
||||||
5
.github/workflows/pytest.yaml
vendored
5
.github/workflows/pytest.yaml
vendored
|
|
@ -11,8 +11,10 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10"]
|
python-version: ["3.10"]
|
||||||
|
core-version: ["2023.2.5", "2023.3.6", "2023.4.0", "dev"]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
@ -20,7 +22,8 @@ jobs:
|
||||||
- name: Install Home Assistant
|
- name: Install Home Assistant
|
||||||
uses: ./.github/workflows/install_dependencies
|
uses: ./.github/workflows/install_dependencies
|
||||||
with:
|
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.
|
- name: Click here for troubleshooting steps if tests break again.
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
4
.github/workflows/update-readme.yml
vendored
4
.github/workflows/update-readme.yml
vendored
|
|
@ -20,11 +20,11 @@ jobs:
|
||||||
- name: Install Home Assistant
|
- name: Install Home Assistant
|
||||||
uses: ./.github/workflows/install_dependencies
|
uses: ./.github/workflows/install_dependencies
|
||||||
with:
|
with:
|
||||||
python_version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
- name: Install markdown-code-runner and README code dependencies
|
- name: Install markdown-code-runner and README code dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install markdown-code-runner pandas tabulate
|
pip install markdown-code-runner==1.0.0 pandas tabulate
|
||||||
|
|
||||||
- name: Link custom_components/adaptive_lighting
|
- name: Link custom_components/adaptive_lighting
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
- [:sunny: Sun Position](#sunny-sun-position)
|
||||||
- [:thermometer: Color Temperature](#thermometer-color-temperature)
|
- [:thermometer: Color Temperature](#thermometer-color-temperature)
|
||||||
- [:high_brightness: Brightness](#high_brightness-brightness)
|
- [: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)
|
- [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors)
|
||||||
|
|
||||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||||
|
|
@ -379,7 +381,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting
|
||||||
#### :high_brightness: Brightness
|
#### :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
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://medium.com/@hudsonbrendon"><img src="https://avatars.githubusercontent.com/u/5201888?v=4?s=100" width="100px;" alt="Hudson Brendon"/><br /><sub><b>Hudson Brendon</b></sub></a><br /><a href="#translation-hudsonbrendon" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://medium.com/@hudsonbrendon"><img src="https://avatars.githubusercontent.com/u/5201888?v=4?s=100" width="100px;" alt="Hudson Brendon"/><br /><sub><b>Hudson Brendon</b></sub></a><br /><a href="#translation-hudsonbrendon" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gvssr"><img src="https://avatars.githubusercontent.com/u/61377476?v=4?s=100" width="100px;" alt="Gabriel Visser"/><br /><sub><b>Gabriel Visser</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=gvssr" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/gvssr"><img src="https://avatars.githubusercontent.com/u/61377476?v=4?s=100" width="100px;" alt="Gabriel Visser"/><br /><sub><b>Gabriel Visser</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=gvssr" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/glebsterx"><img src="https://avatars.githubusercontent.com/u/8779304?v=4?s=100" width="100px;" alt="Gleb"/><br /><sub><b>Gleb</b></sub></a><br /><a href="#translation-glebsterx" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/glebsterx"><img src="https://avatars.githubusercontent.com/u/8779304?v=4?s=100" width="100px;" alt="Gleb"/><br /><sub><b>Gleb</b></sub></a><br /><a href="#translation-glebsterx" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ghost"><img src="https://avatars.githubusercontent.com/u/10137?v=4?s=100" width="100px;" alt="Deleted user"/><br /><sub><b>Deleted user</b></sub></a><br /><a href="#translation-ghost" title="Translation">🌍</a></td>
|
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://omg.dje.li/"><img src="https://avatars.githubusercontent.com/u/103232?v=4?s=100" width="100px;" alt="Avi Miller"/><br /><sub><b>Avi Miller</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Djelibeybi" title="Documentation">📖</a> <a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Djelibeybi" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://omg.dje.li/"><img src="https://avatars.githubusercontent.com/u/103232?v=4?s=100" width="100px;" alt="Avi Miller"/><br /><sub><b>Avi Miller</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Djelibeybi" title="Documentation">📖</a> <a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Djelibeybi" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/denysdovhan"><img src="https://avatars.githubusercontent.com/u/3459374?v=4?s=100" width="100px;" alt="Denys Dovhan"/><br /><sub><b>Denys Dovhan</b></sub></a><br /><a href="#translation-denysdovhan" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/denysdovhan"><img src="https://avatars.githubusercontent.com/u/3459374?v=4?s=100" width="100px;" alt="Denys Dovhan"/><br /><sub><b>Denys Dovhan</b></sub></a><br /><a href="#translation-denysdovhan" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="http://davidstenbeck.com/"><img src="https://avatars.githubusercontent.com/u/3330933?v=4?s=100" width="100px;" alt="David Stenbeck"/><br /><sub><b>David Stenbeck</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Davst" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="http://davidstenbeck.com/"><img src="https://avatars.githubusercontent.com/u/3330933?v=4?s=100" width="100px;" alt="David Stenbeck"/><br /><sub><b>David Stenbeck</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=Davst" title="Documentation">📖</a></td>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,6 @@
|
||||||
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
|
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
|
||||||
"iot_class": "calculated",
|
"iot_class": "calculated",
|
||||||
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
|
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
|
||||||
"requirements": [],
|
"requirements": ["ulid-transform"],
|
||||||
"version": "1.10.0"
|
"version": "1.10.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,10 @@ from homeassistant.util.color import (
|
||||||
color_RGB_to_xy,
|
color_RGB_to_xy,
|
||||||
color_temperature_to_rgb,
|
color_temperature_to_rgb,
|
||||||
color_xy_to_hs,
|
color_xy_to_hs,
|
||||||
|
color_xy_to_RGB,
|
||||||
)
|
)
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
import ulid_transform
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
|
@ -181,21 +183,58 @@ BRIGHTNESS_ATTRS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keep a short domain version for the context instances (which can only be 36 chars)
|
# 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:
|
def _int_to_base36(num: int) -> str:
|
||||||
bits = i.bit_length()
|
"""
|
||||||
if signed:
|
Convert an integer to its base-36 representation using numbers and uppercase letters.
|
||||||
# Make room for the sign bit.
|
|
||||||
bits += 1
|
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
|
||||||
return i.to_bytes((bits + 7) // 8, "little", signed=signed)
|
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:
|
def _short_hash(string: str, length: int = 4) -> str:
|
||||||
"""Create a hash of 'string' with length 'length'."""
|
"""Create a hash of 'string' with length 'length'."""
|
||||||
str_hash_bytes = _int_to_bytes(hash(string), signed=True)
|
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
|
||||||
return base64.b85encode(str_hash_bytes)[: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(
|
def create_context(
|
||||||
|
|
@ -203,12 +242,16 @@ def create_context(
|
||||||
) -> Context:
|
) -> Context:
|
||||||
"""Create a context that can identify this integration."""
|
"""Create a context that can identify this integration."""
|
||||||
# Use a hash for the name because otherwise the context might become
|
# Use a hash for the name because otherwise the context might become
|
||||||
# too long (max len == 36) to fit in the database.
|
# too long (max len == 26) to fit in the database.
|
||||||
name_hash = _short_hash(name)
|
|
||||||
# Pack index with base85 to maximize the number of contexts we can create
|
# 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.
|
# before we exceed the 26-character limit and are forced to wrap.
|
||||||
index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
|
time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
|
||||||
context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
|
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
|
parent_id = parent.id if parent else None
|
||||||
return Context(id=context_id, parent_id=parent_id)
|
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'."""
|
"""Check whether this integration created 'context'."""
|
||||||
if context is None:
|
if context is None:
|
||||||
return False
|
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):
|
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)
|
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(
|
def _attributes_have_changed(
|
||||||
light: str,
|
light: str,
|
||||||
old_attributes: dict[str, Any],
|
old_attributes: dict[str, Any],
|
||||||
|
|
@ -636,6 +714,11 @@ def _attributes_have_changed(
|
||||||
adapt_color: bool,
|
adapt_color: bool,
|
||||||
context: Context,
|
context: Context,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
if adapt_color:
|
||||||
|
old_attributes, new_attributes = _add_missing_attributes(
|
||||||
|
old_attributes, new_attributes
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
adapt_brightness
|
adapt_brightness
|
||||||
and ATTR_BRIGHTNESS in old_attributes
|
and ATTR_BRIGHTNESS in old_attributes
|
||||||
|
|
@ -690,21 +773,6 @@ def _attributes_have_changed(
|
||||||
context.id,
|
context.id,
|
||||||
)
|
)
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1076,7 +1144,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
||||||
return
|
return
|
||||||
# See #80. Doesn't check if transitions differ but it does the job.
|
# See #80. Doesn't check if transitions differ but it does the job.
|
||||||
last_service_data = self.turn_on_off_listener.last_service_data
|
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(
|
_LOGGER.debug(
|
||||||
"%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')",
|
"%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')",
|
||||||
self._name,
|
self._name,
|
||||||
|
|
@ -1141,14 +1209,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
||||||
if lights is None:
|
if lights is None:
|
||||||
lights = self._lights
|
lights = self._lights
|
||||||
|
|
||||||
if not force and self._only_once:
|
|
||||||
return
|
|
||||||
|
|
||||||
filtered_lights = []
|
filtered_lights = []
|
||||||
for light in lights:
|
if not force:
|
||||||
# Don't adapt lights that haven't finished prior transitions.
|
if self._only_once:
|
||||||
if force or not self.turn_on_off_listener.transition_timers.get(light):
|
return
|
||||||
filtered_lights.append(light)
|
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:
|
if not filtered_lights:
|
||||||
return
|
return
|
||||||
|
|
@ -1606,33 +1683,28 @@ class TurnOnOffListener:
|
||||||
|
|
||||||
def start_transition_timer(self, light: str) -> None:
|
def start_transition_timer(self, light: str) -> None:
|
||||||
"""Mark a light as manually controlled."""
|
"""Mark a light as manually controlled."""
|
||||||
_LOGGER.debug("Start transition timer for %s", light)
|
last_service_data = self.last_service_data.get(light)
|
||||||
last_service_data = self.last_service_data
|
if not last_service_data:
|
||||||
if (
|
_LOGGER.debug("This should not ever happen. Please report to the devs.")
|
||||||
not last_service_data
|
|
||||||
or light not in last_service_data
|
|
||||||
or ATTR_TRANSITION not in last_service_data[light]
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
last_transition = last_service_data.get(ATTR_TRANSITION)
|
||||||
delay = last_service_data[light][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():
|
async def reset():
|
||||||
|
ValueError("TEST")
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Transition finished for light %s",
|
"Transition finished for light %s",
|
||||||
light,
|
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):
|
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
|
||||||
"""Set the time after which the lights are automatically reset."""
|
"""Set the time after which the lights are automatically reset."""
|
||||||
|
|
@ -1755,7 +1827,7 @@ class TurnOnOffListener:
|
||||||
async def state_changed_event_listener(self, event: Event) -> None:
|
async def state_changed_event_listener(self, event: Event) -> None:
|
||||||
"""Track 'state_changed' events."""
|
"""Track 'state_changed' events."""
|
||||||
entity_id = event.data.get(ATTR_ENTITY_ID, "")
|
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
|
return
|
||||||
|
|
||||||
new_state = event.data.get("new_state")
|
new_state = event.data.get("new_state")
|
||||||
|
|
@ -1800,6 +1872,10 @@ class TurnOnOffListener:
|
||||||
entity_id,
|
entity_id,
|
||||||
)
|
)
|
||||||
self.last_state_change[entity_id] = [new_state]
|
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)
|
self.start_transition_timer(entity_id)
|
||||||
elif old_state is not None:
|
elif old_state is not None:
|
||||||
self.last_state_change[entity_id].append(new_state)
|
self.last_state_change[entity_id].append(new_state)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
```bash
|
||||||
docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest
|
docker run -v ${PWD}:/app basnijholt/adaptive-lighting:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows:
|
- In windows command prompt, the command is:
|
||||||
|
```bash
|
||||||
```bash
|
docker run -v %cd%:/app basnijholt/adaptive-lighting:latest
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Tests for Adaptive Lighting switches."""
|
"""Tests for Adaptive Lighting switches."""
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from random import randint
|
from random import randint
|
||||||
|
|
@ -47,6 +48,8 @@ from homeassistant.components.light import (
|
||||||
ATTR_BRIGHTNESS_PCT,
|
ATTR_BRIGHTNESS_PCT,
|
||||||
ATTR_COLOR_TEMP_KELVIN,
|
ATTR_COLOR_TEMP_KELVIN,
|
||||||
ATTR_RGB_COLOR,
|
ATTR_RGB_COLOR,
|
||||||
|
ATTR_TRANSITION,
|
||||||
|
ATTR_XY_COLOR,
|
||||||
)
|
)
|
||||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||||
from homeassistant.components.light import SERVICE_TURN_OFF
|
from homeassistant.components.light import SERVICE_TURN_OFF
|
||||||
|
|
@ -60,6 +63,7 @@ from homeassistant.const import (
|
||||||
CONF_LIGHTS,
|
CONF_LIGHTS,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_PLATFORM,
|
CONF_PLATFORM,
|
||||||
|
EVENT_STATE_CHANGED,
|
||||||
SERVICE_TURN_ON,
|
SERVICE_TURN_ON,
|
||||||
STATE_OFF,
|
STATE_OFF,
|
||||||
STATE_ON,
|
STATE_ON,
|
||||||
|
|
@ -70,6 +74,7 @@ from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util.color import color_temperature_mired_to_kelvin
|
from homeassistant.util.color import color_temperature_mired_to_kelvin
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
import pytest
|
import pytest
|
||||||
|
import ulid_transform
|
||||||
import voluptuous.error
|
import voluptuous.error
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, mock_area_registry
|
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
|
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
|
@pytest.fixture
|
||||||
def reset_time_zone():
|
def reset_time_zone():
|
||||||
|
|
@ -208,6 +222,59 @@ async def setup_lights_and_switch(hass, extra_conf=None):
|
||||||
return switch, lights_instances
|
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):
|
async def test_adaptive_lighting_switches(hass):
|
||||||
"""Test switches created for adaptive_lighting integration."""
|
"""Test switches created for adaptive_lighting integration."""
|
||||||
entry, _ = await setup_switch(hass, {})
|
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.parametrize("lat,long,timezone", LAT_LONG_TZS)
|
||||||
|
@pytest.mark.dependency("test_adaptive_lighting_switches")
|
||||||
async def test_adaptive_lighting_time_zones_with_default_settings(
|
async def test_adaptive_lighting_time_zones_with_default_settings(
|
||||||
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
|
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
|
||||||
):
|
):
|
||||||
|
|
@ -581,6 +649,7 @@ async def test_unconventional_sun_events(hass):
|
||||||
assert_expected_color_temp(state)
|
assert_expected_color_temp(state)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
async def test_turn_on_off_listener_not_tracking_untracked_lights(hass):
|
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."""
|
"""Test that lights that are not in a Adaptive Lighting switch aren't tracked."""
|
||||||
switch, _ = await setup_lights_and_switch(hass)
|
switch, _ = await setup_lights_and_switch(hass)
|
||||||
|
|
@ -600,6 +669,7 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass):
|
||||||
assert light not in switch.turn_on_off_listener.lights
|
assert light not in switch.turn_on_off_listener.lights
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
async def test_manual_control(hass):
|
async def test_manual_control(hass):
|
||||||
"""Test the 'manual control' tracking."""
|
"""Test the 'manual control' tracking."""
|
||||||
switch, (light, *_) = await setup_lights_and_switch(hass)
|
switch, (light, *_) = await setup_lights_and_switch(hass)
|
||||||
|
|
@ -747,6 +817,7 @@ async def test_manual_control(hass):
|
||||||
assert all([not manual_control[eid] for eid in switch._lights])
|
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):
|
async def test_auto_reset_manual_control(hass):
|
||||||
switch, (light, *_) = await setup_lights_and_switch(
|
switch, (light, *_) = await setup_lights_and_switch(
|
||||||
hass, {CONF_AUTORESET_CONTROL: 0.1}
|
hass, {CONF_AUTORESET_CONTROL: 0.1}
|
||||||
|
|
@ -791,6 +862,7 @@ async def test_auto_reset_manual_control(hass):
|
||||||
assert not manual_control[light.entity_id]
|
assert not manual_control[light.entity_id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
async def test_apply_service(hass):
|
async def test_apply_service(hass):
|
||||||
"""Test adaptive_lighting.apply service."""
|
"""Test adaptive_lighting.apply service."""
|
||||||
switch, (_, _, light) = await setup_lights_and_switch(hass)
|
switch, (_, _, light) = await setup_lights_and_switch(hass)
|
||||||
|
|
@ -854,6 +926,9 @@ async def test_apply_service(hass):
|
||||||
assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN]
|
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):
|
async def test_switch_off_on_off(hass):
|
||||||
"""Test switch rapid off_on_off."""
|
"""Test switch rapid off_on_off."""
|
||||||
|
|
||||||
|
|
@ -904,85 +979,7 @@ async def test_switch_off_on_off(hass):
|
||||||
assert state == STATE_OFF
|
assert state == STATE_OFF
|
||||||
|
|
||||||
|
|
||||||
async def test_significant_change(hass):
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
"""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]
|
|
||||||
|
|
||||||
|
|
||||||
def test_color_difference_redmean():
|
def test_color_difference_redmean():
|
||||||
"""Test color_difference_redmean function."""
|
"""Test color_difference_redmean function."""
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
|
|
@ -992,14 +989,6 @@ def test_color_difference_redmean():
|
||||||
color_difference_redmean((0, 0, 0), (255, 255, 255))
|
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():
|
def test_attributes_have_changed():
|
||||||
"""Test _attributes_have_changed function."""
|
"""Test _attributes_have_changed function."""
|
||||||
attributes_1 = {
|
attributes_1 = {
|
||||||
|
|
@ -1027,12 +1016,240 @@ def test_attributes_have_changed():
|
||||||
assert _attributes_have_changed(
|
assert _attributes_have_changed(
|
||||||
old_attributes=attributes_1, new_attributes=attrs, **kwargs
|
old_attributes=attributes_1, new_attributes=attrs, **kwargs
|
||||||
)
|
)
|
||||||
# Switch from rgb_color to color_temp
|
_LOGGER.debug("Test switch from color_temp to rgb_color")
|
||||||
assert _attributes_have_changed(
|
assert not _attributes_have_changed(
|
||||||
old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 100},
|
old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702},
|
||||||
new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0)},
|
new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (255, 166, 87)},
|
||||||
**kwargs,
|
**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):
|
async def test_unload_switch(hass):
|
||||||
|
|
@ -1101,6 +1318,7 @@ async def test_turn_on_and_off_when_already_at_that_state(hass):
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
async def test_async_update_at_interval(hass):
|
async def test_async_update_at_interval(hass):
|
||||||
"""Test '_async_update_at_interval' method."""
|
"""Test '_async_update_at_interval' method."""
|
||||||
_, switch = await setup_switch(hass, {})
|
_, switch = await setup_switch(hass, {})
|
||||||
|
|
@ -1108,6 +1326,7 @@ async def test_async_update_at_interval(hass):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("separate_turn_on_commands", (True, False))
|
@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):
|
async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
|
||||||
"""Test 'separate_turn_on_commands' argument."""
|
"""Test 'separate_turn_on_commands' argument."""
|
||||||
switch, (light, *_) = await setup_lights_and_switch(
|
switch, (light, *_) = await setup_lights_and_switch(
|
||||||
|
|
@ -1144,6 +1363,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
|
||||||
assert sleep_color_temp != color_temp
|
assert sleep_color_temp != color_temp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||||
async def test_area(hass):
|
async def test_area(hass):
|
||||||
switch, (light, *_) = await setup_lights_and_switch(hass)
|
switch, (light, *_) = await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
|
@ -1180,6 +1400,7 @@ async def test_area(hass):
|
||||||
assert light.entity_id not in switch.turn_on_off_listener.last_service_data
|
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):
|
async def test_change_switch_settings_service(hass):
|
||||||
"""Test adaptive_lighting.change_switch_settings service."""
|
"""Test adaptive_lighting.change_switch_settings service."""
|
||||||
switch, (_, _, light) = await setup_lights_and_switch(hass)
|
switch, (_, _, light) = await setup_lights_and_switch(hass)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue