merge with origin\main

This commit is contained in:
Benjamin Auquite 2023-04-10 05:21:51 -05:00
commit dced97b5ca
22 changed files with 1433 additions and 517 deletions

View file

@ -400,7 +400,9 @@
"avatar_url": "https://avatars.githubusercontent.com/u/2219836?v=4",
"profile": "https://github.com/th3w1zard1",
"contributions": [
"code"
"code",
"bug",
"maintenance"
]
},
{
@ -429,6 +431,15 @@
"contributions": [
"code"
]
},
{
"login": "igiannakas",
"name": "igiannakas",
"avatar_url": "https://avatars.githubusercontent.com/u/59056762?v=4",
"profile": "https://github.com/igiannakas",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

1
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1 @@
* @basnijholt

View file

@ -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:
<!-- This is where you get to tell us what went wrong. When doing so, please make sure to include *all* relevant information.
### Required information for bug reports:
Please try to include:
* 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).
* When possible, steps to reliably produce the bug.
-->
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:

View file

@ -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

View file

@ -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: ''
---

View file

@ -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: ''
---

25
.github/update-services.py vendored Normal file
View file

@ -0,0 +1,25 @@
from pathlib import Path
import sys
import yaml
sys.path.append(str(Path(__file__).parent.parent))
from custom_components.adaptive_lighting import const # noqa: E402
services_filename = "custom_components/adaptive_lighting/services.yaml"
with open(services_filename) as f:
services = yaml.safe_load(f)
for service_name, dct in services.items():
_docs = {"set_manual_control": const.DOCS_MANUAL_CONTROL, "apply": const.DOCS_APPLY}
alternative_docs = _docs.get(service_name, const.DOCS)
for field_name, field in dct["fields"].items():
description = alternative_docs.get(field_name, const.DOCS[field_name])
field["description"] = description
comment = "# This file is auto-generated by .github/update-services.py."
with open(services_filename, "w") as f:
f.write(comment + "\n")
yaml.dump(services, f, sort_keys=False, width=1000, allow_unicode=True)

31
.github/update-strings.py vendored Normal file
View file

@ -0,0 +1,31 @@
import json
from pathlib import Path
import sys
sys.path.append(str(Path(__file__).parent.parent))
from custom_components.adaptive_lighting import const # noqa: E402
strings_fname = "custom_components/adaptive_lighting/strings.json"
en_fname = "custom_components/adaptive_lighting/translations/en.json"
with open(strings_fname) as f:
strings = json.load(f)
data = {k: f"{k}: {const.DOCS[k]}" for k, _, _ in const.VALIDATION_TUPLES}
strings["options"]["step"]["init"]["data"] = data
with open(strings_fname, "w") as f:
json.dump(strings, f, indent=2, ensure_ascii=False)
f.write("\n")
# Sync changes from strings.json to en.json
with open(en_fname) as f:
en = json.load(f)
en["config"]["step"]["user"] = strings["config"]["step"]["user"]
en["options"]["step"]["init"]["data"] = data
with open(en_fname, "w") as f:
json.dump(en, f, indent=2, ensure_ascii=False)
f.write("\n")

View file

@ -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

View file

@ -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: |
@ -31,8 +34,8 @@ jobs:
echo "::notice::### 4. ERROR:homeassistant.setup:Setup failed for 'component': Unable to import component: No module named ''module'' ###"
echo "::notice::### 5. add 'component'.'module' (without the '') from the above log into the 'required' list inside of 'test_dependencies.py' ###"
echo "::notice::### 6. Try again! If more issues persist they should be easily solvable by reading the verbose logs now. ###"
- name: Run pytest
timeout-minutes: 60
- name: Link custom_components/adaptive_lighting
run: |
cd core
@ -46,6 +49,10 @@ jobs:
ln -fs ../../../tests adaptive_lighting
cd -
- name: Run pytest
timeout-minutes: 60
run: |
cd core
python3 -X dev -m pytest \
-qq \
--timeout=9 \

View file

@ -20,23 +20,42 @@ jobs:
- name: Install Home Assistant
uses: ./.github/workflows/install_dependencies
with:
python_version: "3.10"
python-version: "3.10"
- name: Install pandas and tabulate
- 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: |
cd core/homeassistant/components
ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
- name: Run markdown-code-runner
run: markdown-code-runner --debug README.md
- name: Commit updated README.md
- name: Run update strings.json
run: python .github/update-strings.py
- name: Run update services.yaml
run: python .github/update-services.py
- name: Commit updated README.md, strings.json, and services.yaml
id: commit
run: |
git add README.md
git add -u .
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git diff --quiet && git diff --staged --quiet || git commit -m "Update README.md"
if git diff --quiet && git diff --staged --quiet; then
echo "No changes in README.md, strings.json, and services.yaml, skipping commit."
echo "commit_status=skipped" >> $GITHUB_ENV
else
git commit -m "Update README.md, strings.json, and services.yaml"
echo "commit_status=committed" >> $GITHUB_ENV
fi
- name: Push changes
if: env.commit_status == 'committed'
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -12,7 +12,7 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/asottile/pyupgrade

106
README.md
View file

@ -1,14 +1,16 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-46-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-47-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
![](https://github.com/home-assistant/brands/raw/b4a168b9af282ef916e120d31091ecd5e3c35e66/core_integrations/adaptive_lighting/icon.png)
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.
@ -29,7 +31,7 @@ Adaptive Lighting provides four switches (using "living_room" as an example comp
Adaptive Lighting is designed to automatically detect when you or another source (e.g., automation) manually changes light settings 🕹️.
When this occurs, the affected light is marked as "manually controlled," and Adaptive Lighting will not make further adjustments until the light is turned off and back on or reset using the `adaptive_lighting.set_manual_control` service call.
This feature is available when take_over_control is enabled.
This feature is available when `take_over_control` is enabled.
Additionally, enabling detect_non_ha_changes allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings.
The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖.
@ -47,7 +49,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as
- [`adaptive_lighting.change_switch_settings`](#adaptive_lightingchange_switch_settings)
- [:robot: Automation examples](#robot-automation-examples)
- [Additional Information](#additional-information)
- [Troubleshooting](#troubleshooting)
- [:sos: Troubleshooting](#sos-troubleshooting)
- [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions)
- [:bulb: Lights Not Responding or Turning On by Themselves](#bulb-lights-not-responding-or-turning-on-by-themselves)
- [:signal_strength: WiFi Networks](#signal_strength-wifi-networks)
@ -58,6 +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 `transition_until_sleep: true`](#while-using-transition_until_sleep-true)
- [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@ -81,44 +84,43 @@ All of the configuration options are listed below, along with their default valu
The YAML and frontend configuration methods support all of the options listed below.
<!-- START_CODE -->
<!-- import os, sys -->
<!-- sys.path.append(os.path.abspath(".")) -->
<!-- from custom_components.adaptive_lighting import const -->
<!-- markdown_table = const.generate_markdown_table() -->
<!-- print(markdown_table) -->
<!-- from homeassistant.components.adaptive_lighting import _docs_helpers -->
<!-- print(_docs_helpers.generate_config_markdown_table()) -->
<!-- END_CODE -->
<!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| Variable name | Description | Default | Type |
|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------|
| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s |
| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` |
| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | `1` | `float` 0-6553 |
| `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 |
| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 |
| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` |
| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` |
| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 |
| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 |
| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 |
| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 |
| `sleep_brightness` | Brightness of lights in sleep mode. 😴 | `1` | `int` 1-100 |
| `sleep_rgb_or_color_temp` | Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` |
| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`). 😴 | `1000` | `int` 1000-10000 |
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). 🌈 | `[255, 56, 0]` | RGB color |
| `sunrise_time` | Set a fixed time for sunrise. 🌅 | `None` | `str` |
| `max_sunrise_time` | Set the latest virtual sunrise time, allowing for earlier real sunrises. 🌅 | `None` | `str` |
| `sunrise_offset` | Adjust sunrise time with a positive or negative offset. ⏰ | `0` | `int` |
| `sunset_time` | Set a fixed time for sunset. 🌇 | `None` | `str` |
| `min_sunset_time` | Set the earliest virtual sunset time, allowing for later real sunsets. 🌇 | `None` | `str` |
| `sunset_offset` | Adjust sunset time with a positive or negative offset. ⏰ | `0` | `int` |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 |
| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` |
| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 |
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` |
| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` |
| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` |
| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` |
| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` |
| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` |
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
| `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. ⏲️ | `0` | `int` 0-10000 |
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. ⏲️ | `0` | `float > 0` |
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` |
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 |
<!-- END_OUTPUT -->
@ -155,26 +157,42 @@ adaptive_lighting:
`adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand.
| Service data attribute | Required | Description |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------- |
| `entity_id` | ✅ | The `entity_id` of the switch with the settings to apply. |
| `lights` | ❌ | A light (or list of lights) to apply the settings to. |
| `transition` | ❌ | The number of seconds for the transition. |
| `adapt_brightness` | ❌ | Whether to change the brightness of the light or not. |
| `adapt_color` | ❌ | Whether to adapt the color on supporting lights. |
| `prefer_rgb_color` | ❌ | Whether to prefer RGB color adjustment over of native light color temperature when possible. |
| `turn_on_lights` | ❌ | Whether to turn on lights that are currently off. |
<!-- START_CODE -->
<!-- from homeassistant.components.adaptive_lighting import _docs_helpers -->
<!-- print(_docs_helpers.generate_apply_markdown_table()) -->
<!-- END_CODE -->
<!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:-------------------------------------------------------------------------------------|:-----------|:---------------------|
| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ✅ | list of `entity_id`s |
| `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 |
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool |
| `turn_on_lights` | Whether to turn on lights that are currently off. 🔆 | ❌ | bool |
<!-- END_OUTPUT -->
#### `adaptive_lighting.set_manual_control`
`adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted.
| Service data attribute | Required | Description |
| ---------------------- | -------- | --------------------------------------------------------------------------------------------------- |
| `entity_id` | ✅ | The `entity_id` of the switch in which to (un)mark the light as being "manually controlled". |
| `lights` | ❌ | entity_id(s) of lights, if not specified, all lights in the switch are selected. |
| `manual_control` | ❌ | Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true |
<!-- START_CODE -->
<!-- from homeassistant.components.adaptive_lighting import _docs_helpers -->
<!-- print(_docs_helpers.generate_set_manual_control_markdown_table()) -->
<!-- END_CODE -->
<!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:-----------------------------------------------------------------------------------------------|:-----------|:---------------------|
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s |
| `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s |
| `manual_control` | Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 | ❌ | bool |
<!-- END_OUTPUT -->
#### `adaptive_lighting.change_switch_settings`
`adaptive_lighting.change_switch_settings` (new in 1.7.0) Change any of the above configuration options of Adaptive Lighting (such as `sunrise_time` or `prefer_rgb_color`) with a service call directly from your script/automation.
@ -298,7 +316,7 @@ For more details on adding the integration and setting options, refer to the [do
Adaptive Lighting was initially inspired by @claytonjn's [hass-circadian\_lighting](https://github.com/claytonjn/hass-circadian_lighting), but has since been entirely rewritten and expanded with new features.
# Troubleshooting
# :sos: Troubleshooting
Encountering issues? Enable debug logging in your `configuration.yaml`:
@ -363,6 +381,10 @@ These graphs were generated using the values calculated by the Adaptive Lighting
#### :high_brightness: Brightness
![cl_brightness|690x130](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/8/58ebd994b62a8b1abfb3497a5288d923ff4e2330.PNG)
#### While using `transition_until_sleep: true`
![image](https://user-images.githubusercontent.com/2219836/228949675-f9699624-8abc-466c-bb04-250ce0f495b8.png)
## :busts_in_silhouette: Contributors
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
@ -401,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="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/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://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>
@ -425,10 +446,11 @@ These graphs were generated using the values calculated by the Adaptive Lighting
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brebtatv"><img src="https://avatars.githubusercontent.com/u/10747062?v=4?s=100" width="100px;" alt="Tomáš Valigura"/><br /><sub><b>Tomáš Valigura</b></sub></a><br /><a href="#translation-brebtatv" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/th3w1zard1"><img src="https://avatars.githubusercontent.com/u/2219836?v=4?s=100" width="100px;" alt="Benjamin Auquite"/><br /><sub><b>Benjamin Auquite</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=th3w1zard1" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/th3w1zard1"><img src="https://avatars.githubusercontent.com/u/2219836?v=4?s=100" width="100px;" alt="Benjamin Auquite"/><br /><sub><b>Benjamin Auquite</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=th3w1zard1" title="Code">💻</a> <a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3Ath3w1zard1" title="Bug reports">🐛</a> <a href="#maintenance-th3w1zard1" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/skycarl"><img src="https://avatars.githubusercontent.com/u/43375685?v=4?s=100" width="100px;" alt="Skyler Carlson"/><br /><sub><b>Skyler Carlson</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=skycarl" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/firstof9"><img src="https://avatars.githubusercontent.com/u/1105672?v=4?s=100" width="100px;" alt="Chris"/><br /><sub><b>Chris</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=firstof9" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/raman325"><img src="https://avatars.githubusercontent.com/u/7243222?v=4?s=100" width="100px;" alt="Raman Gupta"/><br /><sub><b>Raman Gupta</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=raman325" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/igiannakas"><img src="https://avatars.githubusercontent.com/u/59056762?v=4?s=100" width="100px;" alt="igiannakas"/><br /><sub><b>igiannakas</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=igiannakas" title="Code">💻</a></td>
</tr>
</tbody>
<tfoot>

View file

@ -0,0 +1,116 @@
from typing import Any
from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv
import pandas as pd
import voluptuous as vol
from .const import (
DOCS,
DOCS_APPLY,
DOCS_MANUAL_CONTROL,
SET_MANUAL_CONTROL_SCHEMA,
VALIDATION_TUPLES,
apply_service_schema,
)
def _format_voluptuous_instance(instance):
coerce_type = None
min_val = None
max_val = None
for validator in instance.validators:
if isinstance(validator, vol.Coerce):
coerce_type = validator.type.__name__
elif isinstance(validator, (vol.Clamp, vol.Range)):
min_val = validator.min
max_val = validator.max
if min_val is not None and max_val is not None:
return f"`{coerce_type}` {min_val}-{max_val}"
elif min_val is not None:
return f"`{coerce_type} > {min_val}`"
elif max_val is not None:
return f"`{coerce_type} < {max_val}`"
else:
return f"`{coerce_type}`"
def _type_to_str(type_: Any) -> str:
"""Convert a (voluptuous) type to a string."""
if type_ == cv.entity_ids:
return "list of `entity_id`s"
elif type_ in (bool, int, float, str):
return f"`{type_.__name__}`"
elif type_ == cv.boolean:
return "bool"
elif isinstance(type_, vol.All):
return _format_voluptuous_instance(type_)
elif isinstance(type_, vol.In):
return f"one of `{type_.container}`"
elif isinstance(type_, selector.SelectSelector):
return f"one of `{type_.config['options']}`"
elif isinstance(type_, selector.ColorRGBSelector):
return "RGB color"
else:
raise ValueError(f"Unknown type: {type_}")
def generate_config_markdown_table():
import pandas as pd
rows = []
for k, default, type_ in VALIDATION_TUPLES:
description = DOCS[k]
row = {
"Variable name": f"`{k}`",
"Description": description,
"Default": f"`{default}`",
"Type": _type_to_str(type_),
}
rows.append(row)
df = pd.DataFrame(rows)
return df.to_markdown(index=False)
def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]:
result = {}
for key, value in schema.schema.items():
if isinstance(key, vol.Optional):
default_value = key.default
result[key.schema] = (default_value, value)
return result
def _generate_service_markdown_table(
schema: dict[str, tuple[Any, Any]], alternative_docs: dict[str, str] = None
):
schema = _schema_to_dict(schema)
rows = []
for k, (default, type_) in schema.items():
if alternative_docs is not None and k in alternative_docs:
description = alternative_docs[k]
else:
description = DOCS[k]
row = {
"Service data attribute": f"`{k}`",
"Description": description,
"Required": "" if default == vol.UNDEFINED else "",
"Type": _type_to_str(type_),
}
rows.append(row)
df = pd.DataFrame(rows)
return df.to_markdown(index=False)
def generate_apply_markdown_table():
return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY)
def generate_set_manual_control_markdown_table():
return _generate_service_markdown_table(
SET_MANUAL_CONTROL_SCHEMA, DOCS_MANUAL_CONTROL
)

View file

@ -1,6 +1,7 @@
"""Constants for the Adaptive Lighting integration."""
from homeassistant.components.light import VALID_TRANSITION
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
@ -14,16 +15,14 @@ DOMAIN = "adaptive_lighting"
SUN_EVENT_NOON = "solar_noon"
SUN_EVENT_MIDNIGHT = "solar_midnight"
DOCS = {}
DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"}
CONF_NAME, DEFAULT_NAME = "name", "default"
DOCS[CONF_NAME] = "Display name for this switch. 📝"
CONF_LIGHTS, DEFAULT_LIGHTS = "lights", []
DOCS[CONF_LIGHTS] = (
"List of light entities to be controlled by Adaptive " "Lighting (may be empty). 🌟"
)
DOCS[CONF_LIGHTS] = "List of light entity_ids to be controlled (may be empty). 🌟"
CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = (
"detect_non_ha_changes",
@ -45,11 +44,14 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = (
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
DOCS[CONF_INITIAL_TRANSITION] = (
"Duration of the first transition when lights turn " "from `off` to `on`. ⏲️"
"Duration of the first transition when lights turn "
"from `off` to `on` in seconds. ⏲️"
)
CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1
DOCS[CONF_SLEEP_TRANSITION] = "Duration of transition when 'sleep mode' is toggled. 😴"
DOCS[CONF_SLEEP_TRANSITION] = (
'Duration of transition when "sleep mode" is toggled ' "in seconds. 😴"
)
CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90
DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄"
@ -73,9 +75,10 @@ DOCS[CONF_ONLY_ONCE] = (
)
CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False
DOCS[
CONF_PREFER_RGB_COLOR
] = "Use RGB color adjustment instead of native light color temperature. 🌈"
DOCS[CONF_PREFER_RGB_COLOR] = (
"Whether to prefer RGB color adjustment over "
"light color temperature when possible. 🌈"
)
CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = (
"separate_turn_on_commands",
@ -87,47 +90,53 @@ DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = (
)
CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1
DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness of lights in sleep mode. 😴"
DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness percentage of lights in sleep mode. 😴"
CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000
DOCS[CONF_SLEEP_COLOR_TEMP] = (
"Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is "
"`color_temp`). 😴"
"`color_temp`) in Kelvin. 😴"
)
CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0]
DOCS[CONF_SLEEP_RGB_COLOR] = (
"RGB color in sleep mode (used when " "`sleep_rgb_or_color_temp` is 'rgb_color'). 🌈"
"RGB color in sleep mode (used when " '`sleep_rgb_or_color_temp` is "rgb_color"). 🌈'
)
CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = (
"sleep_rgb_or_color_temp",
"color_temp",
)
DOCS[
CONF_SLEEP_RGB_OR_COLOR_TEMP
] = "Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙"
DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = (
'Use either `"rgb_color"` or `"color_temp"` ' "in sleep mode. 🌙"
)
CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0
DOCS[CONF_SUNRISE_OFFSET] = "Adjust sunrise time with a positive or negative offset. ⏰"
DOCS[CONF_SUNRISE_OFFSET] = (
"Adjust sunrise time with a positive or negative offset " "in seconds. ⏰"
)
CONF_SUNRISE_TIME = "sunrise_time"
DOCS[CONF_SUNRISE_TIME] = "Set a fixed time for sunrise. 🌅"
DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅"
CONF_MAX_SUNRISE_TIME = "max_sunrise_time"
DOCS[CONF_MAX_SUNRISE_TIME] = (
"Set the latest virtual sunrise time, allowing" " for earlier real sunrises. 🌅"
"Set the latest virtual sunrise time (HH:MM:SS), allowing"
" for earlier real sunrises. 🌅"
)
CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0
DOCS[CONF_SUNSET_OFFSET] = "Adjust sunset time with a positive or negative offset. ⏰"
DOCS[
CONF_SUNSET_OFFSET
] = "Adjust sunset time with a positive or negative offset in seconds. ⏰"
CONF_SUNSET_TIME = "sunset_time"
DOCS[CONF_SUNSET_TIME] = "Set a fixed time for sunset. 🌇"
DOCS[CONF_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for sunset. 🌇"
CONF_MIN_SUNSET_TIME = "min_sunset_time"
DOCS[CONF_MIN_SUNSET_TIME] = (
"Set the earliest virtual sunset time, allowing" " for later real sunsets. 🌇"
"Set the earliest virtual sunset time (HH:MM:SS), allowing"
" for later real sunsets. 🌇"
)
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
@ -140,18 +149,32 @@ DOCS[CONF_TAKE_OVER_CONTROL] = (
CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45
DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. 🕑"
CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = (
"transition_until_sleep",
False,
)
DOCS[CONF_ADAPT_UNTIL_SLEEP] = (
"When enabled, Adaptive Lighting will treat sleep settings as the minimum, "
"transitioning to these values after sunset. 🌙"
)
CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0
DOCS[CONF_ADAPT_DELAY] = (
"Wait time (seconds) between light turn on and Adaptive Lighting applying "
"changes. Helps avoid flickering. ⏲️"
"changes. Might help to avoid flickering. ⏲️"
)
CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0
DOCS[CONF_SEND_SPLIT_DELAY] = (
"Wait time (milliseconds) between commands when using `separate_turn_on_commands`. "
"Helps ensure correct handling. ⏲️"
"Delay (ms) between `separate_turn_on_commands` for lights that don't support "
"simultaneous brightness and color setting. ⏲️"
)
CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0
DOCS[CONF_AUTORESET_CONTROL] = (
"Automatically reset the manual control after a number of seconds. "
"Set to 0 to disable. ⏲️"
)
SLEEP_MODE_SWITCH = "sleep_mode_switch"
ADAPT_COLOR_SWITCH = "adapt_color_switch"
@ -160,18 +183,40 @@ ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener"
UNDO_UPDATE_LISTENER = "undo_update_listener"
NONE_STR = "None"
ATTR_ADAPT_COLOR = "adapt_color"
DOCS[ATTR_ADAPT_COLOR] = "Whether to adapt the color on supporting lights. 🌈"
ATTR_ADAPT_BRIGHTNESS = "adapt_brightness"
DOCS[ATTR_ADAPT_BRIGHTNESS] = "Whether to adapt the brightness of the light. 🌞"
SERVICE_SET_MANUAL_CONTROL = "set_manual_control"
CONF_MANUAL_CONTROL = "manual_control"
DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒"
SERVICE_APPLY = "apply"
CONF_TURN_ON_LIGHTS = "turn_on_lights"
DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆"
SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings"
CONF_USE_DEFAULTS = "use_defaults"
DOCS[CONF_USE_DEFAULTS] = (
"Sets the default values not specified in this service call. Options: "
'"current" (default, retains current values), "factory" (resets to '
'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️'
)
TURNING_OFF_DELAY = 5
DOCS_MANUAL_CONTROL = {
CONF_ENTITY_ID: "The `entity_id` of the switch in which to (un)mark the "
"light as being `manually controlled`. 📝",
CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the "
"switch are selected. 💡",
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the '
'light from the "manual_control" list. 🔒',
}
DOCS_APPLY = {
CONF_ENTITY_ID: "The `entity_id` of the switch with the settings to apply. 📝",
CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡",
}
def int_between(min_int, max_int):
"""Return an integer between 'min_int' and 'max_int'."""
@ -185,6 +230,7 @@ VALIDATION_TUPLES = [
(CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION),
(CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION),
(CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION),
(CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool),
(CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int),
(CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)),
(CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)),
@ -220,6 +266,11 @@ VALIDATION_TUPLES = [
(CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool),
(CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)),
(CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float),
(
CONF_AUTORESET_CONTROL,
DEFAULT_AUTORESET_CONTROL,
int_between(0, 365 * 24 * 60 * 60), # 1 year max
),
]
@ -270,55 +321,28 @@ _DOMAIN_SCHEMA = vol.Schema(
)
def _format_voluptuous_instance(instance):
coerce_type = None
min_val = None
max_val = None
for validator in instance.validators:
if isinstance(validator, vol.Coerce):
coerce_type = validator.type.__name__
elif isinstance(validator, (vol.Clamp, vol.Range)):
min_val = validator.min
max_val = validator.max
if min_val is not None and max_val is not None:
return f"`{coerce_type}` {min_val}-{max_val}"
elif min_val is not None:
return f"`{coerce_type} > {min_val}`"
elif max_val is not None:
return f"`{coerce_type} < {max_val}`"
else:
return f"`{coerce_type}`"
def generate_markdown_table():
import pandas as pd
rows = []
for k, default, type_ in VALIDATION_TUPLES:
description = DOCS[k]
if type_ == cv.entity_ids:
type_ = "list of `entity_id`s"
elif type_ in (bool, int, float, str):
type_ = f"`{type_.__name__}`"
elif isinstance(type_, vol.All):
type_ = _format_voluptuous_instance(type_)
elif isinstance(type_, vol.In):
type_ = f"one of `{type_.container}`"
elif isinstance(type_, selector.SelectSelector):
type_ = f"one of `{type_.config['options']}`"
elif isinstance(type_, selector.ColorRGBSelector):
type_ = "RGB color"
else:
raise ValueError(f"Unknown type: {type_}")
row = {
"Variable name": f"`{k}`",
"Description": description,
"Default": f"`{default}`",
"Type": type_,
def apply_service_schema(initial_transition: int = 1):
"""Return the schema for the apply service."""
return vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(
CONF_TRANSITION,
default=initial_transition,
): VALID_TRANSITION,
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
}
rows.append(row)
)
df = pd.DataFrame(rows)
return df.to_markdown(index=False)
SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
}
)

View file

@ -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.8.0"
"requirements": ["ulid-transform"],
"version": "1.11.0"
}

View file

@ -1,191 +1,186 @@
# This file is auto-generated by .github/update-services.py.
apply:
description: Applies the current Adaptive Lighting settings to lights.
fields:
entity_id:
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
example: switch.adaptive_lighting_default
description: The `entity_id` of the switch with the settings to apply. 📝
selector:
entity:
integration: adaptive_lighting
domain: switch
multiple: false
lights:
description: entity_id(s) of lights, if not specified, all lights in the switch are selected.
example: light.bedroom_ceiling
description: A light (or list of lights) to apply the settings to. 💡
selector:
entity:
domain: light
multiple: true
transition:
description: Transition of the lights.
description: Duration of transition when lights change, in seconds. 🕑
example: 10
selector:
text:
text: null
adapt_brightness:
description: "Adapt the 'brightness', default: true"
description: Whether to adapt the brightness of the light. 🌞
example: true
selector:
boolean:
boolean: null
adapt_color:
description: "Adapt the color_temp/color_rgb, default: true"
description: Whether to adapt the color on supporting lights. 🌈
example: true
selector:
boolean:
boolean: null
prefer_rgb_color:
description: "Prefer to use color_rgb over color_temp if possible, default: false"
description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈
example: false
selector:
boolean:
boolean: null
turn_on_lights:
description: "Turn on the lights that are off, default: false"
description: Whether to turn on lights that are currently off. 🔆
example: false
selector:
boolean:
boolean: null
set_manual_control:
description: Mark whether a light is 'manually controlled'.
fields:
entity_id:
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
example: switch.adaptive_lighting_default
description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝
selector:
entity:
integration: adaptive_lighting
domain: switch
multiple: false
lights:
description: entity_id(s) of lights, if not specified, all lights in the switch are selected.
example: light.bedroom_ceiling
description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡
selector:
entity:
domain: light
multiple: true
manual_control:
description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true"
description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒
example: true
default: true
selector:
boolean:
boolean: null
change_switch_settings:
description: "Change any settings you'd like in the switch. All options here are the same as in the config flow."
description: Change any settings you'd like in the switch. All options here are the same as in the config flow.
fields:
entity_id:
description: "entity_id of the Adaptive Lighting switch."
description: Entity ID of the switch. 📝
required: true
selector:
entity:
domain: switch
use_defaults:
description: "(default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation."
example: "current"
description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️'
example: current
required: false
default: "current"
default: current
selector:
select:
options:
- "current"
- "configuration"
- "factory"
- current
- configuration
- factory
include_config_in_attributes:
description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)"
description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝
required: false
selector:
boolean:
boolean: null
turn_on_lights:
description: "Turn on the lights that are off, default: false"
description: Whether to turn on lights that are currently off. 🔆
example: false
required: false
selector:
boolean:
boolean: null
initial_transition:
description: "initial_transition: When lights turn 'off' to 'on'. (seconds)"
description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️
example: 1
required: false
selector:
text:
text: null
sleep_transition:
description: "sleep_transition: When 'sleep_state' changes. (seconds)"
description: Duration of transition when "sleep mode" is toggled in seconds. 😴
example: 1
required: false
selector:
text:
text: null
max_brightness:
description: "max_brightness: Highest brightness of lights during a cycle. (%)"
description: Maximum brightness percentage. 💡
required: false
example: 100
selector:
text:
text: null
max_color_temp:
description: "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)"
description: Coldest color temperature in Kelvin. ❄️
required: false
example: 5500
selector:
text:
text: null
min_brightness:
description: "min_brightness: Lowest brightness of lights during a cycle. (%)"
description: Minimum brightness percentage. 💡
required: false
example: 1
selector:
text:
text: null
min_color_temp:
description: "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)"
description: Warmest color temperature in Kelvin. 🔥
required: false
example: 2000
selector:
text:
text: null
only_once:
description: "only_once: Only adapt the lights when turning them on."
description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄
example: false
required: false
selector:
boolean:
boolean: null
prefer_rgb_color:
description: "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible."
description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈
required: false
example: false
selector:
boolean:
boolean: null
separate_turn_on_commands:
description: "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights)."
description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀
required: false
example: false
selector:
boolean:
boolean: null
send_split_delay:
description: "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly."
description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️
required: false
example: 0
selector:
boolean:
boolean: null
sleep_brightness:
description: "sleep_brightness, Brightness setting for Sleep Mode. (%)"
description: Brightness percentage of lights in sleep mode. 😴
required: false
example: 1
selector:
text:
text: null
sleep_rgb_or_color_temp:
description: "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'"
description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙
required: false
example: "color_temp"
example: color_temp
selector:
select:
options:
- "rgb_color"
- "color_temp"
- rgb_color
- color_temp
sleep_rgb_color:
description: "sleep_rgb_color, in RGB"
description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈
required: false
selector:
color_rgb:
color_rgb: null
sleep_color_temp:
description: "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)"
description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴
required: false
example: 1000
selector:
text:
text: null
sunrise_offset:
description: sunrise_offset, in +/- seconds (integer)
description: Adjust sunrise time with a positive or negative offset in seconds. ⏰
required: false
example: 0
selector:
@ -193,58 +188,64 @@ change_switch_settings:
min: 0
max: 86300
sunrise_time:
description: sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location)
description: Set a fixed time (HH:MM:SS) for sunrise. 🌅
required: false
example: ""
example: ''
selector:
time:
time: null
sunset_offset:
description: sunset_offset, in +/- seconds (integer)
description: Adjust sunset time with a positive or negative offset in seconds. ⏰
required: false
example: ""
example: ''
selector:
number:
min: 0
max: 86300
sunset_time:
description: sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)
example: ""
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
example: ''
required: false
selector:
time:
time: null
max_sunrise_time:
description: "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)"
example: ""
description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅
example: ''
required: false
selector:
time:
time: null
min_sunset_time:
description: "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)"
example: ""
description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇
example: ''
required: false
selector:
time:
time: null
take_over_control:
description: "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on."
description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒
required: false
example: true
selector:
boolean:
boolean: null
detect_non_ha_changes:
description: "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)"
description: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️
required: false
example: false
selector:
boolean:
boolean: null
transition:
description: "Transition time when applying a change to the lights (seconds)"
description: Duration of transition when lights change, in seconds. 🕑
required: false
example: 45
selector:
text:
text: null
adapt_delay:
description: "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️
required: false
example: 0
selector:
text:
text: null
autoreset_control_seconds:
description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️
required: false
example: 0
selector:
text: null

View file

@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"title": "Choose a name for the Adaptive Lighting",
"title": "Choose a name for the Adaptive Lighting instance",
"description": "Every instance can contain multiple lights!",
"data": {
"name": "Name"
@ -19,33 +19,35 @@
"title": "Adaptive Lighting options",
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.",
"data": {
"lights": "lights",
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)",
"include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)",
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)",
"interval": "interval: Time between switch updates. (seconds)",
"max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)",
"max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)",
"min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)",
"min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)",
"only_once": "only_once: Only adapt the lights when turning them on.",
"prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.",
"separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).",
"send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.",
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'",
"sleep_rgb_color": "sleep_rgb_color, in RGB",
"sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)",
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)",
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)",
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
"min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
"take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.",
"detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)",
"transition": "Transition time when applying a change to the lights (seconds)",
"adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
"min_brightness": "min_brightness: Minimum brightness percentage. 💡",
"max_brightness": "max_brightness: Maximum brightness percentage. 💡",
"min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥",
"max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️",
"sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴",
"sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
"sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰",
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇",
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️"
}
}
},

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import base64
import bisect
from collections import defaultdict
from collections.abc import Callable, Coroutine
from copy import deepcopy
from dataclasses import dataclass
import datetime
@ -39,7 +39,6 @@ from homeassistant.components.light import (
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_TRANSITION,
VALID_TRANSITION,
is_on,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
@ -86,8 +85,10 @@ from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_to_rgb,
color_xy_to_hs,
color_xy_to_RGB,
)
import homeassistant.util.dt as dt_util
import ulid_transform
import voluptuous as vol
from .const import (
@ -97,6 +98,8 @@ from .const import (
ATTR_ADAPT_COLOR,
ATTR_TURN_ON_OFF_LISTENER,
CONF_ADAPT_DELAY,
CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL,
CONF_DETECT_NON_HA_CHANGES,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
CONF_INITIAL_TRANSITION,
@ -135,11 +138,13 @@ from .const import (
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SERVICE_SET_MANUAL_CONTROL,
SET_MANUAL_CONTROL_SCHEMA,
SLEEP_MODE_SWITCH,
SUN_EVENT_MIDNIGHT,
SUN_EVENT_NOON,
TURNING_OFF_DELAY,
VALIDATION_TUPLES,
apply_service_schema,
replace_none_str,
)
@ -178,21 +183,58 @@ BRIGHTNESS_ATTRS = {
}
# Keep a short domain version for the context instances (which can only be 36 chars)
_DOMAIN_SHORT = "adapt_lgt"
_DOMAIN_SHORT = "al"
def _int_to_bytes(i: int, signed: bool = False) -> bytes:
bits = i.bit_length()
if signed:
# Make room for the sign bit.
bits += 1
return i.to_bytes((bits + 7) // 8, "little", signed=signed)
def _int_to_base36(num: int) -> str:
"""
Convert an integer to its base-36 representation using numbers and uppercase letters.
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
alphanumeric representation. The function takes an integer `num` as input and returns
its base-36 representation as a string.
Parameters
----------
num
The integer to convert to base-36.
Returns
-------
str
The base-36 representation of the input integer.
Examples
--------
>>> num = 123456
>>> base36_num = int_to_base36(num)
>>> print(base36_num)
'2N9'
"""
ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return ALPHANUMERIC_CHARS[0]
base36_str = ""
base = len(ALPHANUMERIC_CHARS)
while num:
num, remainder = divmod(num, base)
base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str
return base36_str
def _short_hash(string: str, length: int = 4) -> str:
"""Create a hash of 'string' with length 'length'."""
str_hash_bytes = _int_to_bytes(hash(string), signed=True)
return base64.b85encode(str_hash_bytes)[:length]
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
def _remove_vowels(input_str: str, length: int = 4) -> str:
vowels = "aeiouAEIOU"
output_str = "".join([char for char in input_str if char not in vowels])
return output_str.zfill(length)[:length]
def create_context(
@ -200,12 +242,16 @@ def create_context(
) -> Context:
"""Create a context that can identify this integration."""
# Use a hash for the name because otherwise the context might become
# too long (max len == 36) to fit in the database.
name_hash = _short_hash(name)
# too long (max len == 26) to fit in the database.
# Pack index with base85 to maximize the number of contexts we can create
# before we exceed the 36-character limit and are forced to wrap.
index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
# before we exceed the 26-character limit and are forced to wrap.
time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
name_hash = _short_hash(name)
which_short = _remove_vowels(which)
context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:"
chars_left = 26 - len(context_id_start)
index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:]
context_id = context_id_start + index_packed
parent_id = parent.id if parent else None
return Context(id=context_id, parent_id=parent_id)
@ -214,7 +260,7 @@ def is_our_context(context: Context | None) -> bool:
"""Check whether this integration created 'context'."""
if context is None:
return False
return context.id.startswith(_DOMAIN_SHORT)
return f":{_DOMAIN_SHORT}:" in context.id
def _split_service_data(service_data, adapt_brightness, adapt_color):
@ -265,7 +311,7 @@ def find_switch_for_lights(
is_on: bool = False,
) -> AdaptiveSwitch:
"""Find the switch that controls the lights in 'lights'."""
switches = _get_switches_with_lights(hass, lights, is_on)
switches = _get_switches_with_lights(hass, lights)
if len(switches) == 1:
return switches[0]
elif len(switches) > 1:
@ -330,7 +376,7 @@ def _get_switches_from_service_call(
async def handle_change_switch_settings(
switch: AdaptiveSwitch, service_call: ServiceCall
):
) -> None:
"""Allows HASS to change config values via a service call."""
data = service_call.data
@ -378,6 +424,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},
@ -473,7 +520,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.manual_control[light] = True
_fire_manual_control_event(switch, light, service_call.context)
else:
switch.turn_on_off_listener.reset(*all_lights)
@ -493,20 +539,9 @@ async def async_setup_entry(
domain=DOMAIN,
service=SERVICE_APPLY,
service_func=handle_apply,
schema=vol.Schema(
{
vol.Optional("entity_id"): cv.entity_ids,
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(
CONF_TRANSITION,
default=switch._initial_transition, # pylint: disable=protected-access
): VALID_TRANSITION,
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
}
),
schema=apply_service_schema(
switch._initial_transition
), # pylint: disable=protected-access
)
# Register `set_manual_control` service
@ -514,13 +549,7 @@ async def async_setup_entry(
domain=DOMAIN,
service=SERVICE_SET_MANUAL_CONTROL,
service_func=handle_set_manual_control,
schema=vol.Schema(
{
vol.Optional("entity_id"): cv.entity_ids,
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
}
),
schema=SET_MANUAL_CONTROL_SCHEMA,
)
args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string}
@ -596,7 +625,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
def _supported_features(hass: HomeAssistant, light: str):
state = hass.states.get(light)
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
supported = {
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
}
@ -642,6 +671,41 @@ def color_difference_redmean(
return math.sqrt(red_term + green_term + blue_term)
# All comparisons should be done with RGB since
# converting anything to color temp is inaccurate.
def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
if ATTR_RGB_COLOR in attributes:
return attributes
rgb = None
if ATTR_COLOR_TEMP_KELVIN in attributes:
rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN])
elif ATTR_XY_COLOR in attributes:
rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR])
if rgb is not None:
attributes[ATTR_RGB_COLOR] = rgb
_LOGGER.debug(f"Converted {attributes} to rgb {rgb}")
else:
_LOGGER.debug("No suitable conversion found")
return attributes
def _add_missing_attributes(
old_attributes: dict[str, Any],
new_attributes: dict[str, Any],
) -> dict[str, Any]:
if not any(
attr in old_attributes and attr in new_attributes
for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR]
):
old_attributes = _convert_attributes(old_attributes)
new_attributes = _convert_attributes(new_attributes)
return old_attributes, new_attributes
def _attributes_have_changed(
light: str,
old_attributes: dict[str, Any],
@ -650,6 +714,11 @@ def _attributes_have_changed(
adapt_color: bool,
context: Context,
) -> bool:
if adapt_color:
old_attributes, new_attributes = _add_missing_attributes(
old_attributes, new_attributes
)
if (
adapt_brightness
and ATTR_BRIGHTNESS in old_attributes
@ -704,21 +773,6 @@ def _attributes_have_changed(
context.id,
)
return True
switched_color_temp = (
ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes
)
switched_to_rgb_color = (
ATTR_COLOR_TEMP_KELVIN in old_attributes
and ATTR_COLOR_TEMP_KELVIN not in new_attributes
)
if switched_color_temp or switched_to_rgb_color:
# Light switched from RGB mode to color_temp or visa versa
_LOGGER.debug(
"'%s' switched from RGB mode to color_temp or visa versa",
light,
)
return True
return False
@ -816,10 +870,21 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._only_once = data[CONF_ONLY_ONCE]
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
self._transition = data[CONF_TRANSITION]
self._adapt_delay = data[CONF_ADAPT_DELAY]
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]:
_LOGGER.warning(
"%s: Config mismatch: 'detect_non_ha_changes: true' "
"requires 'take_over_control' to be enabled. Adjusting config "
"and continuing setup with `take_over_control: true`.",
self._name,
)
self._take_over_control = True
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
self._expand_light_groups() # updates manual control timers
_loc = get_astral_location(self.hass)
if isinstance(_loc, tuple):
# Astral v2.2
@ -831,6 +896,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._sun_light_settings = SunLightSettings(
name=self._name,
astral_location=location,
adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP],
max_brightness=data[CONF_MAX_BRIGHTNESS],
max_color_temp=data[CONF_MAX_COLOR_TEMP],
min_brightness=data[CONF_MIN_BRIGHTNESS],
@ -893,6 +959,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
def _expand_light_groups(self) -> None:
all_lights = _expand_light_groups(self.hass, self._lights)
self.turn_on_off_listener.lights.update(all_lights)
self.turn_on_off_listener.set_auto_reset_manual_control_times(
all_lights, self._auto_reset_manual_control_time
)
self._lights = list(all_lights)
async def _setup_listeners(self, _=None) -> None:
@ -945,6 +1014,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(
@ -1013,9 +1088,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:
@ -1025,14 +1097,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if prefer_rgb_color is None:
prefer_rgb_color = self._prefer_rgb_color
if "transition" in features:
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
)
# Build service data.
service_data = {ATTR_ENTITY_ID: light}
features = _supported_features(self.hass, light)
# Check transition == 0 to fix #378
if "transition" in features and transition > 0:
service_data[ATTR_TRANSITION] = transition
if "brightness" in features and adapt_brightness:
brightness = round(255 * self._settings["brightness_pct"] / 100)
service_data[ATTR_BRIGHTNESS] = brightness
@ -1059,20 +1135,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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,
# 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 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,
light,
adapt_brightness,
adapt_color,
context,
context.id,
)
):
return
self.turn_on_off_listener.last_service_data[light] = service_data
else:
self.turn_on_off_listener.last_service_data[light] = service_data
async def turn_on(service_data):
_LOGGER.debug(
@ -1124,13 +1199,36 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
)
self.async_write_ha_state()
if lights is None:
lights = self._lights
if (self._only_once and not force) or not lights:
return
await self._adapt_lights(lights, transition, force, context)
async def _adapt_lights(
filtered_lights = []
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._update_manual_control_and_maybe_adapt(
filtered_lights, transition, force, context
)
async def _update_manual_control_and_maybe_adapt(
self,
lights: list[str],
transition: int | None,
@ -1139,34 +1237,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)):
@ -1319,6 +1436,7 @@ class SunLightSettings:
name: str
astral_location: astral.Location
adapt_until_sleep: bool
max_brightness: int
max_color_temp: int
min_brightness: int
@ -1468,7 +1586,12 @@ class SunLightSettings:
delta = self.max_color_temp - self.min_color_temp
ct = (delta * percent) + self.min_color_temp
return 5 * round(ct / 5) # round to nearest 5
return self.min_color_temp
if percent == 0 or not self.adapt_until_sleep:
return self.min_color_temp
if self.adapt_until_sleep and percent < 0:
delta = abs(self.min_color_temp - self.sleep_color_temp)
ct = (delta * abs(1 + percent)) + self.sleep_color_temp
return 5 * round(ct / 5) # round to nearest 5
def get_settings(
self, is_sleep, transition
@ -1491,11 +1614,14 @@ class SunLightSettings:
rgb_color: tuple[float, float, float] = color_temperature_to_rgb(
color_temp_kelvin
)
# backwards compatibility for versions < 1.3.1 - see #403
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
return {
"brightness_pct": brightness_pct,
"color_temp_kelvin": color_temp_kelvin,
"color_temp_mired": color_temp_mired,
"rgb_color": rgb_color,
"xy_color": xy_color,
"hs_color": hs_color,
@ -1519,16 +1645,17 @@ class TurnOnOffListener:
self.sleep_tasks: dict[str, asyncio.Task] = {}
# Tracks which lights are manually controlled
self.manual_control: dict[str, bool] = {}
# Counts the number of times (in a row) a light had a changed state.
self.cnt_significant_changes: dict[str, int] = defaultdict(int)
# Track 'state_changed' events of self.lights resulting from this integration
self.last_state_change: dict[str, list[State]] = {}
# Track last 'service_data' to 'light.turn_on' resulting from this integration
self.last_service_data: dict[str, dict[str, Any]] = {}
# When a state is different `max_cnt_significant_changes` times in a row,
# mark it as manually_controlled.
self.max_cnt_significant_changes = 2
# Track auto reset of manual_control
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
self.auto_reset_manual_control_times: dict[str, float] = {}
# Track light transitions
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
self.remove_listener = self.hass.bus.async_listen(
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
@ -1537,14 +1664,106 @@ class TurnOnOffListener:
EVENT_STATE_CHANGED, self.state_changed_event_listener
)
def _handle_timer(
self,
light: str,
timers_dict: dict[str, _AsyncSingleShotTimer],
delay: float | None,
reset_coroutine: Callable[[], Coroutine[Any, Any, None]],
) -> None:
timer = timers_dict.get(light)
if timer is not None:
if delay is None: # Timer object exists, but should not anymore
timer.cancel()
timers_dict.pop(light)
else: # Timer object already exists, just update the delay and restart it
timer.delay = delay
timer.start()
elif delay is not None: # Timer object does not exist, create it
timer = _AsyncSingleShotTimer(delay, reset_coroutine)
timers_dict[light] = timer
timer.start()
def start_transition_timer(self, light: str) -> None:
"""Mark a light as manually controlled."""
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
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,
)
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."""
if time == 0:
return
for light in lights:
old_time = self.auto_reset_manual_control_times.get(light)
if (old_time is not None) and (old_time != time):
_LOGGER.info(
"Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds."
" This might happen because the light is in multiple swiches"
" or because of a config change.",
light,
old_time,
time,
)
self.auto_reset_manual_control_times[light] = time
def mark_as_manual_control(self, light: str) -> None:
"""Mark a light as manually controlled."""
_LOGGER.debug("Marking '%s' as manually controlled.", light)
self.manual_control[light] = True
delay = self.auto_reset_manual_control_times.get(light)
async def reset():
self.reset(light)
switches = _get_switches_with_lights(self.hass, [light])
for switch in switches:
if not switch.is_on:
continue
await switch._update_attrs_and_maybe_adapt_lights(
[light],
transition=switch._initial_transition,
force=True,
context=switch.create_context("autoreset"),
)
_LOGGER.debug(
"Auto resetting 'manual_control' status of '%s' because"
" it was not manually controlled for %s seconds.",
light,
delay,
)
assert not self.manual_control[light]
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
def reset(self, *lights, reset_manual_control=True) -> None:
"""Reset the 'manual_control' status of the lights."""
for light in lights:
if reset_manual_control:
self.manual_control[light] = False
timer = self.auto_reset_manual_control_timers.pop(light, None)
if timer is not None:
timer.cancel()
self.last_state_change.pop(light, None)
self.last_service_data.pop(light, None)
self.cnt_significant_changes[light] = 0
async def turn_on_off_event_listener(self, event: Event) -> None:
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@ -1599,11 +1818,19 @@ class TurnOnOffListener:
if task is not None:
task.cancel()
self.turn_on_event[eid] = event
timer = self.auto_reset_manual_control_timers.get(eid)
if (
timer is not None
and timer.is_running()
and event.time_fired > timer.start_time
):
# Restart the auto reset timer
timer.start()
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")
@ -1615,11 +1842,7 @@ class TurnOnOffListener:
new_state.context.id,
)
if (
new_state is not None
and new_state.state == STATE_ON
and is_our_context(new_state.context)
):
if new_state is not None and new_state.state == STATE_ON:
# It is possible to have multiple state change events with the same context.
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
# event leads to an instant state change of
@ -1632,21 +1855,33 @@ class TurnOnOffListener:
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
# Philips Hue White GU10 Bluetooth lights).
old_state: list[State] | None = self.last_state_change.get(entity_id)
if (
old_state is not None
and old_state[0].context.id == new_state.context.id
):
# If there is already a state change event from this event (with this
# context) then append it to the already existing list.
_LOGGER.debug(
"State change event of '%s' is already in 'self.last_state_change' (%s)"
" adding this state also",
entity_id,
new_state.context.id,
)
if is_our_context(new_state.context):
if (
old_state is not None
and old_state[0].context.id == new_state.context.id
):
_LOGGER.debug(
"TurnOnOffListener: State change event of '%s' is already"
" in 'self.last_state_change' (%s)"
" adding this state also",
entity_id,
new_state.context.id,
)
self.last_state_change[entity_id].append(new_state)
else:
_LOGGER.debug(
"TurnOnOffListener: New adapt '%s' found for %s",
new_state,
entity_id,
)
self.last_state_change[entity_id] = [new_state]
_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)
else:
self.last_state_change[entity_id] = [new_state]
def is_manually_controlled(
self,
@ -1674,7 +1909,7 @@ class TurnOnOffListener:
):
# Light was already on and 'light.turn_on' was not called by
# the adaptive_lighting integration.
manual_control = self.manual_control[light] = True
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"
@ -1701,64 +1936,56 @@ class TurnOnOffListener:
detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again.
"""
if light not in self.last_state_change:
return False
old_states: list[State] = self.last_state_change[light]
await self.hass.helpers.entity_component.async_update_entity(light)
new_state = self.hass.states.get(light)
last_service_data = self.last_service_data.get(light)
if last_service_data is None:
return
compare_to = functools.partial(
_attributes_have_changed,
light=light,
new_attributes=new_state.attributes,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
context=context,
)
for index, old_state in enumerate(old_states):
changed = compare_to(old_attributes=old_state.attributes)
if not changed:
_LOGGER.debug(
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
light,
index,
context.id,
)
break
last_service_data = self.last_service_data.get(light)
if changed and last_service_data is not None:
# It can happen that the state change events that are associated
# with the last 'light.turn_on' call by this integration were not
# final states. Possibly a later EVENT_STATE_CHANGED happened, where
# the correct target brightness/color was reached.
changed = compare_to(old_attributes=last_service_data)
if not changed:
# Update state and check for a manual change not done in HA.
# Ensure HASS is correctly updating your light's state with
# light.turn_on calls if any problems arise. This
# can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
if switch._detect_non_ha_changes:
_LOGGER.debug(
"%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
" and check if it's last adapt succeeded.",
switch._name,
light,
)
# This update_entity probably isn't necessary now that we're checking
# if transitions finished from our last adapt.
await self.hass.helpers.entity_component.async_update_entity(light)
refreshed_state = self.hass.states.get(light)
_LOGGER.debug(
"%s: Current state of %s: %s",
switch._name,
light,
refreshed_state,
)
changed = compare_to(
old_attributes=last_service_data,
new_attributes=refreshed_state.attributes,
)
if changed:
_LOGGER.debug(
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
light,
context.id,
)
n_changes = self.cnt_significant_changes[light]
if changed:
self.cnt_significant_changes[light] += 1
if n_changes >= self.max_cnt_significant_changes:
# Only mark a light as significantly changing, if changed==True
# N times in a row. We do this because sometimes a state changes
# happens only *after* a new update interval has already started.
self.manual_control[light] = True
_fire_manual_control_event(switch, light, context, is_async=False)
else:
if n_changes > 1:
_LOGGER.debug(
"State of '%s' had 'cnt_significant_changes=%s' but the state"
" changed to the expected settings now",
light,
n_changes,
)
self.cnt_significant_changes[light] = 0
return changed
return True
_LOGGER.debug(
"%s: Light '%s' correctly matches our last adapt's service data, continuing..."
" context.id=%s.",
switch._name,
light,
context.id,
)
return False
async def maybe_cancel_adjusting(
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
@ -1857,3 +2084,45 @@ class TurnOnOffListener:
# other 'off' → 'on' state switches resulting from polling. That
# would mean we 'return True' here.
return False
class _AsyncSingleShotTimer:
def __init__(self, delay, callback):
"""Initialize the timer."""
self.delay = delay
self.callback = callback
self.task = None
self.start_time: int | None = None
async def _run(self):
"""Run the timer. Don't call this directly, use start() instead."""
self.start_time = dt_util.utcnow()
await asyncio.sleep(self.delay)
if self.callback:
if asyncio.iscoroutinefunction(self.callback):
await self.callback()
else:
self.callback()
def is_running(self):
"""Return whether the timer is running."""
return self.task is not None and not self.task.done()
def start(self):
"""Start the timer."""
if self.task is not None and not self.task.done():
self.task.cancel()
self.task = asyncio.create_task(self._run())
def cancel(self):
"""Cancel the timer."""
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

View file

@ -4,7 +4,7 @@
"step": {
"user": {
"title": "Choose a name for the Adaptive Lighting instance",
"description": "Pick a name for this instance. You can run several instances of Adaptive lighting, each of these can contain multiple lights!",
"description": "Every instance can contain multiple lights!",
"data": {
"name": "Name"
}
@ -20,33 +20,35 @@
"title": "Adaptive Lighting options",
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.",
"data": {
"lights": "lights",
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)",
"include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)",
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)",
"interval": "interval: Time between switch updates. (seconds)",
"max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)",
"max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)",
"min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)",
"min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)",
"only_once": "only_once: Only adapt the lights when turning them on.",
"prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.",
"separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).",
"send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.",
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'",
"sleep_rgb_color": "sleep_rgb_color, in RGB",
"sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)",
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)",
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
"sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)",
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
"min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
"take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.",
"detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)",
"transition": "Transition time when applying a change to the lights (seconds)",
"adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
"min_brightness": "min_brightness: Minimum brightness percentage. 💡",
"max_brightness": "max_brightness: Maximum brightness percentage. 💡",
"min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥",
"max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️",
"sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴",
"sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
"sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰",
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇",
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️"
}
}
},

View file

@ -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:

View file

@ -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
@ -10,6 +11,7 @@ from homeassistant.components.adaptive_lighting.const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
ATTR_TURN_ON_OFF_LISTENER,
CONF_AUTORESET_CONTROL,
CONF_DETECT_NON_HA_CHANGES,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
@ -46,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
@ -55,9 +59,11 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS,
CONF_NAME,
CONF_PLATFORM,
EVENT_STATE_CHANGED,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
@ -68,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
@ -104,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():
@ -184,7 +200,7 @@ async def setup_lights_and_switch(hass, extra_conf=None):
# Setup switch
lights = [
"light.bed_light",
ENTITY_LIGHT,
"light.ceiling_lights",
]
assert all(hass.states.get(light) is not None for light in lights)
@ -206,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, {})
@ -233,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
):
@ -425,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)
@ -444,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)
@ -526,11 +598,18 @@ async def test_manual_control(hass):
await turn_switch(True, entity_id)
assert not manual_control[ENTITY_LIGHT]
# Check that manual control is still enabled if set while bulb is off.
# Test issue #37
await turn_light(False)
await change_manual_control(True)
await turn_light(True)
assert manual_control[ENTITY_LIGHT]
# Check that when 'adapt_brightness' is off, changing the brightness
# doesn't mark it as manually controlled but changing color_temp
# does
await turn_light(False) # reset manually controlled status
await turn_light(True)
await turn_light(False)
await turn_light(True) # reset manually controlled status
assert not manual_control[ENTITY_LIGHT]
await switch.adapt_brightness_switch.async_turn_off()
await turn_light(True, brightness=increased_brightness())
@ -584,6 +663,58 @@ 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}
)
context = switch.create_context("test") # needs to be passed to update method
manual_control = switch.turn_on_off_listener.manual_control
async def update():
await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context)
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: light.entity_id, **kwargs},
blocking=True,
)
await hass.async_block_till_done()
await update()
_LOGGER.debug(
"Turn light %s to state %s, to %s", light.entity_id, state, kwargs
)
_LOGGER.debug("Start test auto reset manual control")
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):
_LOGGER.debug("Quick change %s", i)
await turn_light(True, brightness=(i + 1) * 20)
await asyncio.sleep(0.05) # Less than 0.1
assert manual_control[light.entity_id]
await asyncio.sleep(0.3) # Wait the auto reset time
await update()
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)
@ -647,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."""
@ -697,46 +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()
switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass)
await turn_light(True)
await update(force=True) # removes manual control
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# Change brightness by setting state (not using 'light.turn_on')
attributes = hass.states.get(ENTITY_LIGHT).attributes
new_attributes = attributes.copy()
new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255
new_attributes[ATTR_BRIGHTNESS] = new_brightness
bed_light_instance._brightness = new_brightness
assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes):
await update(force=False)
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# On next update the light should be marked as manually controlled
await update(force=False)
assert not 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):
@ -746,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 = {
@ -781,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):
@ -855,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, {})
@ -862,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(
@ -898,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)
@ -934,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)