Merge branch 'master' into context-cleanup

This commit is contained in:
Bas Nijholt 2022-08-29 09:19:21 -07:00 committed by GitHub
commit 34f485881d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 2274 additions and 187 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: [basnijholz, RubenKelevra]

17
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View file

@ -0,0 +1,17 @@
---
name: 'Bug Report'
about: 'Report a bug in adaptive-lighting.'
labels: kind/bug, need/triage
---
#### Version information:
#### Description:
<!-- This is where you get to tell us what went wrong. When doing so, please make sure to include *all* relevant information.
Please try to include:
* 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.
-->

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Getting Help on adaptive-lighting
url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/q-a
about: Q&A section of the discussion tab
- name: Share your idea
url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/ideas
about: And discuss it with the community
- name: General discussions about this component
url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/general
about: General discussions about this component
- name: Share your setup with adaptive-lighting
url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/show-and-tell
about: Or see what other people do with this component

13
.github/ISSUE_TEMPLATE/doc.md vendored Normal file
View file

@ -0,0 +1,13 @@
---
name: 'Documentation Issue'
about: 'Report missing, erroneous docs, broken links or propose new docs'
labels: kind/docs_issue, need/triage
---
#### Location
<!-- In the case of missing/erroneous documentation, where is the error? If possible, a link/URL would be great! -->
#### Description
<!-- Describe the documentation issue. -->

5
.github/ISSUE_TEMPLATE/enhancement.md vendored Normal file
View file

@ -0,0 +1,5 @@
---
name: 'Enhancement'
about: 'Suggest an improvement to an existing feature.'
labels: kind/enhancement, need/triage
---

5
.github/ISSUE_TEMPLATE/feature.md vendored Normal file
View file

@ -0,0 +1,5 @@
---
name: 'Feature'
about: 'Suggest a new feature'
labels: kind/feature, need/triage
---

6
.github/auto-comment.yml vendored Normal file
View file

@ -0,0 +1,6 @@
# Comment to a new issue.
# Disabled
# issueOpened: ""
# Disabled
# pullRequestOpened: ""

64
.github/config.yml vendored Normal file
View file

@ -0,0 +1,64 @@
# Configuration for welcome - https://github.com/behaviorbot/welcome
# Configuration for new-issue-welcome - https://github.com/behaviorbot/new-issue-welcome
# Comment to be posted to on first time issues
newIssueWelcomeComment: >
Thank you for submitting your first issue to this repository! A maintainer
will be here shortly to triage and review.
In the meantime, please double-check that you have provided all the
necessary information to make this process easy! Any information that can
help save additional round trips is useful! We currently aim to give
initial feedback within **two business days**. If this does not happen, feel
free to leave a comment.
Please keep an eye on how this issue will be labeled, as labels give an
overview of priorities, assignments and additional actions requested by the
maintainers:
- "Priority" labels will show how urgent this is for the team.
- "Status" labels will show if this is ready to be worked on, blocked, or in progress.
- "Need" labels will indicate if additional input or analysis is required.
Finally, remember to use [the discussion tab](https://github.com/basnijholt/adaptive-lighting/discussions) if you just need general
support.
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
# Comment to be posted to on PRs from first time contributors in your repository
newPRWelcomeComment: >
Thank you for submitting this PR!
A maintainer will be here shortly to review it.
We are super grateful! Help us by making sure that:
* The context for this PR is clear, with relevant discussion, decisions
and stakeholders linked/mentioned.
* Your contribution itself is clear (code comments, self-review for the
rest) and in its best form.
Getting other community members to do a review would be great help too on
complex PRs. If you are unsure about something, just leave us a comment.
Next steps:
* A maintainer will triage and assign priority to this PR, commenting on
any missing things and potentially assigning a reviewer for high
priority items.
* The PR gets reviews, discussed and approvals as needed.
* The PR is merged by maintainers when it has been approved and comments addressed.
We currently aim to provide initial feedback/triaging within **two business
days**. Please keep an eye on any labelling actions, as these will indicate
priorities and status of your contribution.
We are very grateful for your contribution!
# Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge
# Comment to be posted to on pull requests merged by a first time user
# Currently disabled
#firstPRMergeComment: ""

59
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,59 @@
name: pytest
on:
push:
branches: [master]
pull_request:
jobs:
pytest:
name: Run pytest
runs-on: ubuntu-20.04
timeout-minutes: 60
strategy:
matrix:
python-version: ["3.9", "3.10"]
steps:
- name: Check out code from GitHub
uses: actions/checkout@v3.0.2
- name: Check out code from GitHub
uses: actions/checkout@v3.0.2
with:
repository: home-assistant/core
path: core
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v4.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -r core/requirements.txt
pip install -r core/requirements_test.txt
pip install -e core/
pip install $(python test_dependencies.py)
- name: Run pytest
timeout-minutes: 60
run: |
cd core
# Link homeassitant.components.adaptive_lighting
cd homeassistant/components
ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting
cd -
# Link adaptive_lighting tests
cd tests/components/
ln -fs ../../../tests adaptive_lighting
cd -
python3 -X dev -m pytest \
-qq \
--timeout=9 \
--durations=10 \
--cov="homeassistant" \
--cov-report=xml \
-o console_output_style=count \
-p no:sugar \
tests/components/adaptive_lighting

15
.github/workflows/hassfest.yaml vendored Normal file
View file

@ -0,0 +1,15 @@
name: Validate with hassfest
on:
push:
branches: [master]
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate_hassfest:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: home-assistant/actions/hassfest@master

18
.github/workflows/validate.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Validate
on:
push:
branches: [master]
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate_hacs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"

129
.gitignore vendored Normal file
View file

@ -0,0 +1,129 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

26
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,26 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: trailing-whitespace
- id: end-of-file-fixer
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: https://gitlab.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8
- repo: https://github.com/ambv/black
rev: 22.6.0
hooks:
- id: black
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.3
hooks:
- id: pyupgrade
args: ["--py39-plus"]
- repo: https://github.com/timothycrosley/isort
rev: 5.10.1
hooks:
- id: isort

177
README.md
View file

@ -1,10 +1,172 @@
# Adaptive Lighting component
[![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)
Try out this code by adding https://github.com/basnijholt/adaptive-lighting to your custom repos in HACS and install it!
# Adaptive Lighting component for Home Assistant
See the documentation at https://deploy-preview-14877--home-assistant-docs.netlify.app/integrations/adaptive_lighting/
![](https://github.com/home-assistant/brands/raw/b4a168b9af282ef916e120d31091ecd5e3c35e66/core_integrations/adaptive_lighting/icon.png)
See [this video on Reddit](https://www.reddit.com/r/homeassistant/comments/jabhso/ha_has_it_before_apple_has_even_finished_it_i/) to see how to add the integration and set the options.
_Try out this code by adding https://github.com/basnijholt/adaptive-lighting to your custom repos in [HACS (Home Assistant Community Store)](https://hacs.xyz/) and install it!_
The `adaptive_lighting` platform changes the settings of your lights throughout the day.
It uses the position of the sun to calculate the color temperature and brightness that is most fitting for that time of the day.
Scientific research has shown that this helps to maintain your natural circadian rhythm (your biological clock) and might lead to improved sleep, mood, and general well-being.
In practical terms, this means that after the sun sets, the brightness of your lights will decrease to a certain minimum brightness, while the color temperature will be at its coolest color temperature at noon, after which it will decrease and reach its warmest color at sunset.
Around sunrise, the opposite will happen.
Additionally, the integration provides a way to define and set your lights in "sleep mode".
When "sleep mode" is enabled, the lights will be at a minimal brightness and have a very warm color.
The integration creates 4 switches (in this example the component's name is `"living_room"`):
1. `switch.adaptive_lighting_living_room`, which turns the Adaptive Lighting integration on or off. It has several attributes that show the current light settings.
2. `switch.adaptive_lighting_sleep_mode_living_room`, which when activated, turns on "sleep mode" (you can set a specific `sleep_brightness` and `sleep_color_temp`).
3. `switch.adaptive_lighting_adapt_brightness_living_room`, which sets whether the integration should adapt the brightness of the lights (if supported by the light).
4. `switch.adaptive_lighting_adapt_color_living_room`, which sets whether the integration should adapt the color of the lights (if supported by the light).
## Taking back control
Although having your lights automatically adapt is great most of the time, there might be times at which you want to set the lights to a different color/brightness and keep it that way.
For this purpose, the integration (when `take_over_control` is enabled) automatically detects whether someone (e.g., person toggling the light switch) or something (automation) changes the lights.
If this happens *and* the light is already on, the light that was changed gets marked as "manually controlled" and the Adaptive Lighting component will stop adapting that light until it turns off and on again (or if you use the service call `adaptive_lighting.set_manual_control`).
This mechanism works by listening to all `light.turn_on` calls that change the color or brightness and by noting that the component did not make the call.
Additionally, there is an option to detect all state changes (when `detect_non_ha_changes` is enabled), so also changes to the lights that were not made by a `light.turn_on` call (e.g., through an app or via something outside of Home Assistant.)
It does this by comparing a light's state to Adaptive Lighting's previously used settings.
Whenever a light gets marked as "manually controlled", an `adaptive_lighting.manual_control` event is fired, such that one can use this information in automations.
## Configuration
This integration is both fully configurable through YAML _and_ the frontend. (**Configuration** -> **Integrations** -> **Adaptive Lighting**, **Adaptive Lighting** -> **Options**)
Here, the options in the frontend and in YAML have the same names.
```yaml
# Example configuration.yaml entry
adaptive_lighting:
lights:
- light.living_room_lights
```
### Options
| option | description | required | default | type |
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------|-----------|---------|
| name | The name to use when displaying this switch. | False | default | string |
| lights | List of light entities for Adaptive Lighting to control (may be empty). | False | list | [] |
| prefer_rgb_color | Whether to use RGB color adjustment instead of native light color temperature. | False | False | boolean |
| initial_transition | How long the first transition is when the lights go from `off` to `on`. | False | 1 | time |
| sleep_transition | How long the transition is when when "sleep mode" is toggled | False | 1 | time |
| transition | How long the transition is when the lights change, in seconds. | False | 45 | integer |
| interval | How often to adapt the lights, in seconds. | False | 90 | integer |
| min_brightness | The minimum percent of brightness to set the lights to. | False | 1 | integer |
| max_brightness | The maximum percent of brightness to set the lights to. | False | 100 | integer |
| min_color_temp | The warmest color temperature to set the lights to, in Kelvin. | False | 2000 | integer |
| max_color_temp | The coldest color temperature to set the lights to, in Kelvin. | False | 5500 | integer |
| sleep_brightness | Brightness of lights while the sleep mode is enabled. | False | 1 | integer |
| sleep_color_temp | Color temperature of lights while the sleep mode is enabled. | False | 1000 | integer |
| sunrise_time | Override the sunrise time with a fixed time. | False | time | |
| sunrise_offset | Change the sunrise time with a positive or negative offset. | False | 0 | time |
| sunset_time | Override the sunset time with a fixed time. | False | time | |
| sunset_offset | Change the sunset time with a positive or negative offset. | False | 0 | time |
| only_once | Whether to keep adapting the lights (false) or to only adapt the lights as soon as they are turned on (true). | False | False | boolean |
| take_over_control | If another source calls `light.turn_on` while the lights are on and being adapted, disable Adaptive Lighting. | False | True | boolean |
| detect_non_ha_changes | Whether to detect state changes and stop adapting lights, even not from `light.turn_on`. Needs `take_over_control` to be enabled. Note that by enabling this option, it calls 'homeassistant.update_entity' every 'interval'! | False | False | boolean |
| separate_turn_on_commands | Whether to use separate `light.turn_on` calls for color and brightness, needed for some types of lights | False | False | boolean |
Full example:
```yaml
# Example configuration.yaml entry
adaptive_lighting:
- name: "default"
lights: []
prefer_rgb_color: false
transition: 45
initial_transition: 1
interval: 90
min_brightness: 1
max_brightness: 100
min_color_temp: 2000
max_color_temp: 5500
sleep_brightness: 1
sleep_color_temp: 1000
sunrise_time: "08:00:00" # override the sunrise time
sunrise_offset:
sunset_time:
sunset_offset: 1800 # in seconds or '00:15:00'
take_over_control: true
detect_non_ha_changes: false
only_once: false
```
### Services
`adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand.
| Service data attribute | Optional | Description |
|---------------------------|----------|-------------------------------------------------------------------------|
| `entity_id` | no | The `entity_id` of the switch with the settings to apply. |
| `lights` | no | A light (or list of lights) to apply the settings to. |
| `transition` | yes | The number of seconds for the transition. |
| `adapt_brightness` | yes | Whether to change the brightness of the light or not. |
| `adapt_color` | yes | Whether to adapt the color on supporting lights. |
| `prefer_rgb_color` | yes | Whether to prefer RGB color adjustment over of native light color temperature when possible. |
| `turn_on_lights` | yes | Whether to turn on lights that are currently off. |
`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 | Optional | Description |
|------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------|
| `entity_id` | no | The `entity_id` of the switch in which to (un)mark the light as being "manually controlled". |
| `lights` | yes | entity_id(s) of lights, if not specified, all lights in the switch are selected. |
| `manual_control` | yes | Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true |
## Automation examples
Reset the `manual_control` status of a light after an hour.
```yaml
- alias: "Adaptive lighting: reset manual_control after 1 hour"
mode: parallel
trigger:
platform: event
event_type: adaptive_lighting.manual_control
variables:
light: "{{ trigger.event.data.entity_id }}"
switch: "{{ trigger.event.data.switch }}"
action:
- delay: "01:00:00"
- condition: template
value_template: "{{ light in state_attr(switch, 'manual_control') }}"
- service: adaptive_lighting.set_manual_control
data:
entity_id: "{{ switch }}"
lights: "{{ light }}"
manual_control: false
```
Toggle multiple Adaptive Lighting switches to "sleep mode" using an `input_boolean.sleep_mode`.
```yaml
- alias: "Adaptive lighting: toggle 'sleep mode'"
trigger:
- platform: state
entity_id: input_boolean.sleep_mode
- platform: homeassistant
event: start # in case the states aren't properly restored
variables:
sleep_mode: "{{ states('input_boolean.sleep_mode') }}"
action:
service: "switch.turn_{{ sleep_mode }}"
entity_id:
- switch.adaptive_lighting_sleep_mode_living_room
- switch.adaptive_lighting_sleep_mode_bedroom
```
# Other
See the documentation of the PR at https://deploy-preview-14877--home-assistant-docs.netlify.app/integrations/adaptive_lighting/ and [this video on Reddit](https://www.reddit.com/r/homeassistant/comments/jabhso/ha_has_it_before_apple_has_even_finished_it_i/) to see how to add the integration and set the options.
This integration was originally based of the great work of @claytonjn https://github.com/claytonjn/hass-circadian_lighting, but has been 100% rewritten and extended with new features.
# Having problems?
Please enable debug logging by putting this in `configuration.yaml`:
@ -14,7 +176,7 @@ logger:
logs:
custom_components.adaptive_lighting: debug
```
and after the problem occurs please create an issue with the log.
and after the problem occurs please create an issue with the log (`/config/home-assistant.log`).
### Graphs!
@ -28,3 +190,8 @@ These graphs were generated using the values calculated by the Adaptive Lighting
##### Brightness:
![cl_brightness|690x130](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/8/58ebd994b62a8b1abfb3497a5288d923ff4e2330.PNG)
# Maintainers
- @basnijholt
- @RubenKelevra

View file

@ -1,13 +1,12 @@
"""Adaptive Lighting integration in Home-Assistant."""
import logging
from typing import Any, Dict
import voluptuous as vol
from typing import Any
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_SOURCE
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from .const import (
_DOMAIN_SCHEMA,
@ -36,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup(hass: HomeAssistant, config: Dict[str, Any]):
async def async_setup(hass: HomeAssistant, config: dict[str, Any]):
"""Import integration from config."""
if DOMAIN in config:

View file

@ -1,12 +1,11 @@
"""Config flow for Adaptive Lighting integration."""
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from .const import ( # pylint: disable=unused-import
CONF_LIGHTS,

View file

@ -1,8 +1,7 @@
"""Constants for the Adaptive Lighting integration."""
import voluptuous as vol
from homeassistant.components.light import VALID_TRANSITION
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
ICON = "mdi:theme-light-dark"
@ -17,6 +16,7 @@ CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = (
False,
)
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1
CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90
CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100
CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500
@ -63,6 +63,7 @@ VALIDATION_TUPLES = [
(CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids),
(CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool),
(CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION),
(CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION),
(CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION),
(CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int),
(CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)),

View file

@ -1,9 +1,12 @@
{
"domain": "adaptive_lighting",
"name": "Adaptive Lighting",
"documentation": "https://www.home-assistant.io/integrations/adaptive_lighting",
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
"config_flow": true,
"dependencies": [],
"codeowners": ["@basnijholt"],
"requirements": []
"codeowners": ["@basnijholt", "@RubenKelevra"],
"version": "1.0.16",
"requirements": [],
"iot_class": "calculated"
}

View file

@ -5,7 +5,7 @@ apply:
description: entity_id of the Adaptive Lighting switch.
example: switch.adaptive_lighting_default
lights:
description: entity_id(s) of lights.
description: "entity_id(s) of lights, default: lights of the switch"
example: light.bedroom_ceiling
transition:
description: Transition of the lights.

View file

@ -1,5 +1,4 @@
{
"title": "Adaptive Lighting",
"config": {
"step": {
"user": {
@ -21,7 +20,8 @@
"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 go 'off' to 'on' or when 'sleep_state' changes",
"initial_transition": "initial_transition, when lights go 'off' to 'on'",
"sleep_transition": "sleep_transition, when 'sleep_state' changes",
"interval": "interval, time between switch updates in seconds",
"max_brightness": "max_brightness, in %",
"max_color_temp": "max_color_temp, in Kelvin",

View file

@ -12,11 +12,9 @@ from datetime import timedelta
import functools
import logging
import math
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any
import astral
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
@ -27,19 +25,27 @@ from homeassistant.components.light import (
ATTR_HS_COLOR,
ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
ATTR_WHITE_VALUE,
ATTR_XY_COLOR,
DOMAIN as LIGHT_DOMAIN,
COLOR_MODE_BRIGHTNESS,
COLOR_MODE_COLOR_TEMP,
COLOR_MODE_HS,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
COLOR_MODE_XY,
)
from homeassistant.components.light import (
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_TRANSITION,
SUPPORT_WHITE_VALUE,
VALID_TRANSITION,
is_on,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DOMAIN,
@ -82,6 +88,7 @@ from homeassistant.util.color import (
color_xy_to_hs,
)
import homeassistant.util.dt as dt_util
import voluptuous as vol
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
@ -103,6 +110,7 @@ from .const import (
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SLEEP_BRIGHTNESS,
CONF_SLEEP_COLOR_TEMP,
CONF_SLEEP_TRANSITION,
CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME,
CONF_SUNSET_OFFSET,
@ -125,7 +133,6 @@ from .const import (
_SUPPORT_OPTS = {
"brightness": SUPPORT_BRIGHTNESS,
"white_value": SUPPORT_WHITE_VALUE,
"color_temp": SUPPORT_COLOR_TEMP,
"color": SUPPORT_COLOR,
"transition": SUPPORT_TRANSITION,
@ -154,7 +161,6 @@ COLOR_ATTRS = { # Should ATTR_PROFILE be in here?
BRIGHTNESS_ATTRS = {
ATTR_BRIGHTNESS,
ATTR_WHITE_VALUE,
ATTR_BRIGHTNESS_PCT,
ATTR_BRIGHTNESS_STEP,
ATTR_BRIGHTNESS_STEP_PCT,
@ -176,7 +182,9 @@ def _short_hash(string: str, length: int = 4) -> str:
return base64.b85encode(str_hash_bytes)[:length]
def create_context(name: str, which: str, index: int) -> Context:
def create_context(
name: str, which: str, index: int, parent: Context | None = None
) -> 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.
@ -185,23 +193,52 @@ def create_context(name: str, which: str, index: int) -> Context:
# 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]
return Context(id=context_id)
parent_id = parent.id if parent else None
return Context(id=context_id, parent_id=parent_id)
def is_our_context(context: Optional[Context]) -> bool:
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)
def _split_service_data(service_data, adapt_brightness, adapt_color):
"""Split service_data into two dictionaries (for color and brightness)."""
transition = service_data.get(ATTR_TRANSITION)
if transition is not None:
# Split the transition over both commands
service_data[ATTR_TRANSITION] /= 2
service_datas = []
if adapt_color:
service_data_color = service_data.copy()
service_data_color.pop(ATTR_BRIGHTNESS, None)
service_datas.append(service_data_color)
if adapt_brightness:
service_data_brightness = service_data.copy()
service_data_brightness.pop(ATTR_RGB_COLOR, None)
service_data_brightness.pop(ATTR_COLOR_TEMP, None)
service_datas.append(service_data_brightness)
if not service_datas: # neither adapt_brightness nor adapt_color
return [service_data]
return service_datas
async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
"""Handle the entity service apply."""
hass = switch.hass
data = service_call.data
all_lights = _expand_light_groups(hass, data[CONF_LIGHTS])
all_lights = data[CONF_LIGHTS]
if not all_lights:
all_lights = switch._lights
all_lights = _expand_light_groups(hass, all_lights)
switch.turn_on_off_listener.lights.update(all_lights)
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
await switch._adapt_light( # pylint: disable=protected-access
@ -211,6 +248,7 @@ async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
data[ATTR_ADAPT_COLOR],
data[CONF_PREFER_RGB_COLOR],
force=True,
context=switch.create_context("service", parent=service_call.context),
)
@ -228,25 +266,36 @@ async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: Servic
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.hass, light, service_call.context)
_fire_manual_control_event(switch, light, service_call.context)
else:
switch.turn_on_off_listener.reset(*all_lights)
# pylint: disable=protected-access
await switch._adapt_lights(
all_lights,
transition=switch._initial_transition,
force=True,
context=switch.create_context("service"),
)
if switch.is_on:
await switch._update_attrs_and_maybe_adapt_lights(
all_lights,
transition=switch._initial_transition,
force=True,
context=switch.create_context("service", parent=service_call.context),
)
@callback
def _fire_manual_control_event(
hass: HomeAssistant, light: str, context: Context, is_async=True
switch: AdaptiveSwitch, light: str, context: Context, is_async=True
):
"""Fire an event that 'light' is marked as manual_control."""
hass = switch.hass
fire = hass.bus.async_fire if is_async else hass.bus.fire
fire(f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light}, context=context)
_LOGGER.debug(
"'adaptive_lighting.manual_control' event fired for %s for light %s",
switch.entity_id,
light,
)
fire(
f"{DOMAIN}.manual_control",
{ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id},
context=context,
)
async def async_setup_entry(
@ -287,7 +336,9 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_APPLY,
{
vol.Required(CONF_LIGHTS): cv.entity_ids,
vol.Optional(
CONF_LIGHTS, default=[]
): cv.entity_ids, # pylint: disable=protected-access
vol.Optional(
CONF_TRANSITION,
default=switch._initial_transition, # pylint: disable=protected-access
@ -324,7 +375,7 @@ def validate(config_entry: ConfigEntry):
return data
def match_switch_state_event(event: Event, from_or_to_state: List[str]):
def match_switch_state_event(event: Event, from_or_to_state: list[str]):
"""Match state event when either 'from_state' or 'to_state' matches."""
old_state = event.data.get("old_state")
from_state_match = old_state is not None and old_state.state in from_or_to_state
@ -336,7 +387,7 @@ def match_switch_state_event(event: Event, from_or_to_state: List[str]):
return match
def _expand_light_groups(hass: HomeAssistant, lights: List[str]) -> List[str]:
def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
all_lights = set()
turn_on_off_listener = hass.data[DOMAIN][ATTR_TURN_ON_OFF_LISTENER]
for light in lights:
@ -357,11 +408,34 @@ 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]
return {key for key, value in _SUPPORT_OPTS.items() if supported_features & value}
supported = {
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
}
supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set())
if COLOR_MODE_RGB in supported_color_modes:
supported.add("color")
# Adding brightness here, see
# comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011
supported.add("brightness")
if COLOR_MODE_RGBW in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
if COLOR_MODE_XY in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
if COLOR_MODE_HS in supported_color_modes:
supported.add("color")
supported.add("brightness") # see above url
if COLOR_MODE_COLOR_TEMP in supported_color_modes:
supported.add("color_temp")
supported.add("brightness") # see above url
if COLOR_MODE_BRIGHTNESS in supported_color_modes:
supported.add("brightness")
return supported
def color_difference_redmean(
rgb1: Tuple[float, float, float], rgb2: Tuple[float, float, float]
rgb1: tuple[float, float, float], rgb2: tuple[float, float, float]
) -> float:
"""Distance between colors in RGB space (redmean metric).
@ -372,17 +446,17 @@ def color_difference_redmean(
- https://www.compuphase.com/cmetric.htm
"""
r_hat = (rgb1[0] + rgb2[0]) / 2
delta_r, delta_g, delta_b = [(col1 - col2) for col1, col2 in zip(rgb1, rgb2)]
red_term = (2 + r_hat / 256) * delta_r ** 2
green_term = 4 * delta_g ** 2
blue_term = (2 + (255 - r_hat) / 256) * delta_b ** 2
delta_r, delta_g, delta_b = ((col1 - col2) for col1, col2 in zip(rgb1, rgb2))
red_term = (2 + r_hat / 256) * delta_r**2
green_term = 4 * delta_g**2
blue_term = (2 + (255 - r_hat) / 256) * delta_b**2
return math.sqrt(red_term + green_term + blue_term)
def _attributes_have_changed(
light: str,
old_attributes: Dict[str, Any],
new_attributes: Dict[str, Any],
old_attributes: dict[str, Any],
new_attributes: dict[str, Any],
adapt_brightness: bool,
adapt_color: bool,
context: Context,
@ -405,24 +479,6 @@ def _attributes_have_changed(
)
return True
if (
adapt_brightness
and ATTR_WHITE_VALUE in old_attributes
and ATTR_WHITE_VALUE in new_attributes
):
last_white_value = old_attributes[ATTR_WHITE_VALUE]
current_white_value = new_attributes[ATTR_WHITE_VALUE]
if abs(current_white_value - last_white_value) > BRIGHTNESS_CHANGE:
_LOGGER.debug(
"White Value of '%s' significantly changed from %s to %s with"
" context.id='%s'",
light,
last_white_value,
current_white_value,
context.id,
)
return True
if (
adapt_color
and ATTR_COLOR_TEMP in old_attributes
@ -501,18 +557,24 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
self._initial_transition = data[CONF_INITIAL_TRANSITION]
self._sleep_transition = data[CONF_SLEEP_TRANSITION]
self._interval = data[CONF_INTERVAL]
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 = min(
data[CONF_TRANSITION], self._interval.total_seconds() // 2
)
self._transition = data[CONF_TRANSITION]
_loc = get_astral_location(self.hass)
if isinstance(_loc, tuple):
# Astral v2.2
location, _ = _loc
else:
# Astral v1
location = _loc
self._sun_light_settings = SunLightSettings(
name=self._name,
astral_location=get_astral_location(self.hass),
astral_location=location,
max_brightness=data[CONF_MAX_BRIGHTNESS],
max_color_temp=data[CONF_MAX_COLOR_TEMP],
min_brightness=data[CONF_MIN_BRIGHTNESS],
@ -524,6 +586,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
sunset_offset=data[CONF_SUNSET_OFFSET],
sunset_time=data[CONF_SUNSET_TIME],
time_zone=self.hass.config.time_zone,
transition=data[CONF_TRANSITION],
)
# Set other attributes
@ -531,16 +594,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._state = None
# Tracks 'off' → 'on' state changes
self._on_to_off_event: Dict[str, Event] = {}
self._on_to_off_event: dict[str, Event] = {}
# Tracks 'on' → 'off' state changes
self._off_to_on_event: Dict[str, Event] = {}
self._off_to_on_event: dict[str, Event] = {}
# Locks that prevent light adjusting when waiting for a light to 'turn_off'
self._locks: Dict[str, asyncio.Lock] = {}
self._locks: dict[str, asyncio.Lock] = {}
# To count the number of `Context` instances
self._context_cnt: int = 0
# Set in self._update_attrs_and_maybe_adapt_lights
self._settings: Dict[str, Any] = {}
self._settings: dict[str, Any] = {}
# Set and unset tracker in async_turn_on and async_turn_off
self.remove_listeners = []
@ -566,7 +629,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return self._name
@property
def is_on(self) -> Optional[bool]:
def is_on(self) -> bool | None:
"""Return true if adaptive lighting is on."""
return self._state
@ -632,7 +695,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return self._icon
@property
def device_state_attributes(self) -> Dict[str, Any]:
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the attributes of the switch."""
if not self.is_on:
return {key: None for key in self._settings}
@ -643,7 +706,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
]
return dict(self._settings, manual_control=manual_control)
def create_context(self, which: str = "default") -> Context:
def create_context(
self, which: str = "default", parent: Context | None = None
) -> Context:
"""Create a context that identifies this Adaptive Lighting instance."""
# Right now the highest number of each context_id it can create is
# 'adapt_lgt:XXXX:turn_on:*************'
@ -655,7 +720,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
# The smallest space we have is for adapt_lights, which has
# 8 characters. In base85 encoding, that's enough space to hold values
# up to 2**48 - 1, which should give us plenty of calls before we wrap.
context = create_context(self._name, which, self._context_cnt)
context = create_context(self._name, which, self._context_cnt, parent=parent)
self._context_cnt += 1
return context
@ -688,18 +753,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def _async_update_at_interval(self, now=None) -> None:
await self._update_attrs_and_maybe_adapt_lights(
force=False, context=self.create_context("interval")
transition=self._transition,
force=False,
context=self.create_context("interval"),
)
async def _adapt_light(
self,
light: str,
transition: Optional[int] = None,
adapt_brightness: Optional[bool] = None,
adapt_color: Optional[bool] = None,
prefer_rgb_color: Optional[bool] = None,
transition: int | None = None,
adapt_brightness: bool | None = None,
adapt_color: bool | None = None,
prefer_rgb_color: bool | None = None,
force: bool = False,
context: Optional[Context] = None,
context: Context | None = None,
) -> None:
lock = self._locks.get(light)
if lock is not None and lock.locked():
@ -724,10 +791,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
brightness = round(255 * self._settings["brightness_pct"] / 100)
service_data[ATTR_BRIGHTNESS] = brightness
if "white_value" in features and adapt_brightness:
white_value = round(255 * self._settings["brightness_pct"] / 100)
service_data[ATTR_WHITE_VALUE] = white_value
if (
"color_temp" in features
and adapt_color
@ -747,6 +810,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
and self._detect_non_ha_changes
and not force
and await self.turn_on_off_listener.significant_change(
self,
light,
adapt_brightness,
adapt_color,
@ -754,23 +818,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
):
return
_LOGGER.debug(
"%s: Scheduling 'light.turn_on' with the following 'service_data': %s"
" with context.id='%s'",
self._name,
service_data,
context.id,
)
self.turn_on_off_listener.last_service_data[light] = service_data
if self._separate_turn_on_commands:
service_datas = [
{ATTR_ENTITY_ID: light, key: value}
for key, value in service_data.items()
if key != ATTR_ENTITY_ID
]
else:
service_datas = [service_data]
for service_data in service_datas:
async def turn_on(service_data):
_LOGGER.debug(
"%s: Scheduling 'light.turn_on' with the following 'service_data': %s"
" with context.id='%s'",
self._name,
service_data,
context.id,
)
await self.hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
@ -778,12 +835,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
context=context,
)
if not self._separate_turn_on_commands:
await turn_on(service_data)
else:
# Could be a list of length 1 or 2
service_datas = _split_service_data(
service_data, adapt_brightness, adapt_color
)
await turn_on(service_datas[0])
if len(service_datas) == 2:
transition = service_datas[0].get(ATTR_TRANSITION)
if transition is not None:
await asyncio.sleep(transition)
await turn_on(service_datas[1])
async def _update_attrs_and_maybe_adapt_lights(
self,
lights: Optional[List[str]] = None,
transition: Optional[int] = None,
lights: list[str] | None = None,
transition: int | None = None,
force: bool = False,
context: Optional[Context] = None,
context: Context | None = None,
) -> None:
assert context is not None
_LOGGER.debug(
@ -793,7 +864,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
assert self.is_on
self._settings = self._sun_light_settings.get_settings(
self.sleep_mode_switch.is_on
self.sleep_mode_switch.is_on, transition
)
self.async_write_ha_state()
if lights is None:
@ -804,10 +875,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def _adapt_lights(
self,
lights: List[str],
transition: Optional[int],
lights: list[str],
transition: int | None,
force: bool,
context: Optional[Context],
context: Context | None,
) -> None:
assert context is not None
_LOGGER.debug(
@ -824,6 +895,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if (
self._take_over_control
and self.turn_on_off_listener.is_manually_controlled(
self,
light,
force,
self.adapt_brightness_switch.is_on,
@ -841,6 +913,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def _sleep_mode_switch_state_event(self, event: Event) -> None:
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
return
_LOGGER.debug(
"%s: _sleep_mode_switch_state_event, event: '%s'", self._name, event
@ -848,9 +921,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
# Reset the manually controlled status when the "sleep mode" changes
self.turn_on_off_listener.reset(*self._lights)
await self._update_attrs_and_maybe_adapt_lights(
transition=self._initial_transition,
transition=self._sleep_transition,
force=True,
context=self.create_context("sleep"),
context=self.create_context("sleep", parent=event.context),
)
async def _light_event(self, event: Event) -> None:
@ -891,7 +964,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
lights=[entity_id],
transition=self._initial_transition,
force=True,
context=self.create_context("light_event"),
context=self.create_context("light_event", parent=event.context),
)
elif (
old_state is not None
@ -937,7 +1010,7 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
return self._icon
@property
def is_on(self) -> Optional[bool]:
def is_on(self) -> bool | None:
"""Return true if adaptive lighting is on."""
return self._state
@ -954,10 +1027,12 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
async def async_turn_on(self, **kwargs) -> None:
"""Turn on adaptive lighting sleep mode."""
_LOGGER.debug("%s: Turning on", self._name)
self._state = True
async def async_turn_off(self, **kwargs) -> None:
"""Turn off adaptive lighting sleep mode."""
_LOGGER.debug("%s: Turning off", self._name)
self._state = False
@ -973,21 +1048,41 @@ class SunLightSettings:
min_color_temp: int
sleep_brightness: int
sleep_color_temp: int
sunrise_offset: Optional[datetime.timedelta]
sunrise_time: Optional[datetime.time]
sunset_offset: Optional[datetime.timedelta]
sunset_time: Optional[datetime.time]
sunrise_offset: datetime.timedelta | None
sunrise_time: datetime.time | None
sunset_offset: datetime.timedelta | None
sunset_time: datetime.time | None
time_zone: datetime.tzinfo
transition: int
def get_sun_events(self, date: datetime.datetime) -> Dict[str, float]:
def get_sun_events(self, date: datetime.datetime) -> dict[str, float]:
"""Get the four sun event's timestamps at 'date'."""
def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime:
time = getattr(self, f"{key}_time")
date_time = datetime.datetime.combine(date, time)
utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC)
try: # HA ≤2021.05, https://github.com/basnijholt/adaptive-lighting/issues/128
utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC)
except AttributeError: # HA ≥2021.06
utc_time = date_time.replace(
tzinfo=dt_util.DEFAULT_TIME_ZONE
).astimezone(dt_util.UTC)
return utc_time
def calculate_noon_and_midnight(
sunset: datetime.datetime, sunrise: datetime.datetime
) -> tuple[datetime.datetime, datetime.datetime]:
middle = abs(sunset - sunrise) / 2
if sunset > sunrise:
noon = sunrise + middle
midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1)
else:
midnight = sunset + middle
noon = midnight + timedelta(hours=12) * (
1 if midnight.hour < 12 else -1
)
return noon, midnight
location = self.astral_location
sunrise = (
@ -1002,11 +1097,16 @@ class SunLightSettings:
) + self.sunset_offset
if self.sunrise_time is None and self.sunset_time is None:
solar_noon = location.solar_noon(date, local=False)
solar_midnight = location.solar_midnight(date, local=False)
try:
# Astral v1
solar_noon = location.solar_noon(date, local=False)
solar_midnight = location.solar_midnight(date, local=False)
except AttributeError:
# Astral v2
solar_noon = location.noon(date, local=False)
solar_midnight = location.midnight(date, local=False)
else:
solar_noon = sunrise + (sunset - sunrise) / 2
solar_midnight = sunset + ((sunrise + timedelta(days=1)) - sunset) / 2
(solar_noon, solar_midnight) = calculate_noon_and_midnight(sunset, sunrise)
events = [
(SUN_EVENT_SUNRISE, sunrise.timestamp()),
@ -1029,7 +1129,7 @@ class SunLightSettings:
return events
def relevant_events(self, now: datetime.datetime) -> List[Tuple[str, float]]:
def relevant_events(self, now: datetime.datetime) -> list[tuple[str, float]]:
"""Get the previous and next sun event."""
events = [
self.get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1]
@ -1039,11 +1139,13 @@ class SunLightSettings:
i_now = bisect.bisect([ts for _, ts in events], now.timestamp())
return events[i_now - 1 : i_now + 1]
def calc_percent(self) -> float:
def calc_percent(self, transition: int) -> float:
"""Calculate the position of the sun in %."""
now = dt_util.utcnow()
now_ts = now.timestamp()
today = self.relevant_events(now)
target_time = now + timedelta(seconds=transition)
target_ts = target_time.timestamp()
today = self.relevant_events(target_time)
(_, prev_ts), (next_event, next_ts) = today
h, x = ( # pylint: disable=invalid-name
(prev_ts, next_ts)
@ -1051,7 +1153,7 @@ class SunLightSettings:
else (next_ts, prev_ts)
)
k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1
percentage = (0 - k) * ((now_ts - h) / (h - x)) ** 2 + k
percentage = (0 - k) * ((target_ts - h) / (h - x)) ** 2 + k
return percentage
def calc_brightness_pct(self, percent: float, is_sleep: bool) -> float:
@ -1074,21 +1176,25 @@ class SunLightSettings:
return self.min_color_temp
def get_settings(
self, is_sleep
) -> Dict[str, Union[float, Tuple[float, float], Tuple[float, float, float]]]:
self, is_sleep, transition
) -> dict[str, float | tuple[float, float] | tuple[float, float, float]]:
"""Get all light settings.
Calculating all values takes <0.5ms.
"""
percent = self.calc_percent()
percent = (
self.calc_percent(transition)
if transition is not None
else self.calc_percent(0)
)
brightness_pct = self.calc_brightness_pct(percent, is_sleep)
color_temp_kelvin = self.calc_color_temp_kelvin(percent, is_sleep)
color_temp_mired: float = color_temperature_kelvin_to_mired(color_temp_kelvin)
rgb_color: Tuple[float, float, float] = color_temperature_to_rgb(
rgb_color: tuple[float, float, float] = color_temperature_to_rgb(
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)
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,
@ -1109,19 +1215,19 @@ class TurnOnOffListener:
self.lights = set()
# Tracks 'light.turn_off' service calls
self.turn_off_event: Dict[str, Event] = {}
self.turn_off_event: dict[str, Event] = {}
# Tracks 'light.turn_on' service calls
self.turn_on_event: Dict[str, Event] = {}
self.turn_on_event: dict[str, Event] = {}
# Keep 'asyncio.sleep' tasks that can be cancelled by 'light.turn_on' events
self.sleep_tasks: Dict[str, asyncio.Task] = {}
self.sleep_tasks: dict[str, asyncio.Task] = {}
# Tracks which lights are manually controlled
self.manual_control: Dict[str, bool] = {}
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)
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]] = {}
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]] = {}
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.
@ -1151,7 +1257,7 @@ class TurnOnOffListener:
service = event.data[ATTR_SERVICE]
service_data = event.data[ATTR_SERVICE_DATA]
entity_ids = cv.ensure_list(service_data[ATTR_ENTITY_ID])
entity_ids = cv.ensure_list_csv(service_data[ATTR_ENTITY_ID])
if not any(eid in self.lights for eid in entity_ids):
return
@ -1211,7 +1317,7 @@ class TurnOnOffListener:
# called with a color_temp outside of its range (and HA reports the
# incorrect 'min_mireds' and 'max_mireds', which happens e.g., for
# Philips Hue White GU10 Bluetooth lights).
old_state: Optional[List[State]] = self.last_state_change.get(entity_id)
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
@ -1230,6 +1336,7 @@ class TurnOnOffListener:
def is_manually_controlled(
self,
switch: AdaptiveSwitch,
light: str,
force: bool,
adapt_brightness: bool,
@ -1254,7 +1361,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
_fire_manual_control_event(self.hass, light, turn_on_event.context)
_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"
" adaptive_lighting integration (context.id='%s'), the Adaptive"
@ -1267,6 +1374,7 @@ class TurnOnOffListener:
async def significant_change(
self,
switch: AdaptiveSwitch,
light: str,
adapt_brightness: bool,
adapt_color: bool,
@ -1281,7 +1389,7 @@ class TurnOnOffListener:
"""
if light not in self.last_state_change:
return False
old_states: List[State] = self.last_state_change[light]
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)
compare_to = functools.partial(
@ -1325,7 +1433,7 @@ class TurnOnOffListener:
# 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(self.hass, light, context, is_async=False)
_fire_manual_control_event(switch, light, context, is_async=False)
else:
if n_changes > 1:
_LOGGER.debug(
@ -1339,7 +1447,7 @@ class TurnOnOffListener:
return changed
async def maybe_cancel_adjusting(
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Optional[Event]
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
) -> bool:
"""Cancel the adjusting of a light if it has just been turned off.

View file

@ -0,0 +1,49 @@
{
"title": "Adaptiv Belysning",
"config": {
"step": {
"user": {
"title": "Vælg et navn for denne Adaptive Belysning",
"description": "Vælg et navn til denne konfiguration. Du kan køre flere konfigurationer af Adaptiv Belysning, og hver af dem kan indeholde flere lys!",
"data": {
"name": "Navn"
}
}
},
"abort": {
"already_configured": "Denne enhed er allerede konfigureret"
}
},
"options": {
"step": {
"init": {
"title": "Adaptiv Belysnings indstillinger",
"description": "Alle indstillinger tilhørende en Adaptiv Belysnings komponent. Indstillingernes navne svarer til YAML indstillingernes. Ingen indstillinger vises hvis du allerede har konfigureret den i YAML.",
"data": {
"lights": "lights: lyskilder",
"initial_transition": "initial_transition: Hvor lang overgang når lyset går fra 'off' til 'on' eller når 'sleep_state' skiftes. (i sekunder)",
"interval": "interval: Tid imellem opdateringer (i sekunder)",
"max_brightness": "max_brightness: Højeste lysstyrke i cyklussen. (%)",
"max_color_temp": "max_color_temp: Koldeste lystemperatur i cyklussen. (Kelvin)",
"min_brightness": "min_brightness: Laveste lysstyrke i cyklussen. (%)",
"min_color_temp": "min_color_temp: Varmeste lystemperatur i cyklussen. (Kelvin)",
"only_once": "only_once: Juster udelukkende lysene adaptivt i øjeblikket de tændes.",
"prefer_rgb_color": "prefer_rgb_color: Brug 'rgb_color' istedet for 'color_temp' når muligt.",
"separate_turn_on_commands": "separate_turn_on_commands: Adskil kommandoerne for hver attribut (color, brightness, etc.) ved 'light.turn_on' (nødvendigt for bestemte lys).",
"sleep_brightness": "sleep_brightness, Lysstyrke for Sleep Mode. (%)",
"sleep_color_temp": "sleep_color_temp: Farvetemperatur under Sleep Mode. (Kelvin)",
"sunrise_offset": "sunrise_offset: Hvor længe før (-) eller efter (+) at definere solopgangen i cyklussen (+/- sekunder)",
"sunrise_time": "sunrise_time: Manuel overstyring af solopgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt din lokation. (HH:MM:SS)",
"sunset_offset": "sunset_offset: Hvor længe før (-) eller efter (+) at definere solnedgangen i cyklussen (+/- sekunder)",
"sunset_time": "sunset_time: Manuel overstyring af solnedgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt for din lokation. (HH:MM:SS)",
"take_over_control": "take_over_control: Hvis andet end Adaptiv Belysning kalder 'light.turn_on' på et lys der allerede er tændt, afbryd adaptering af lyset indtil at det tændes igen.",
"detect_non_ha_changes": "detect_non_ha_changes: Registrer alle ændringer på >10% på et lys (også udenfor HA), kræver at 'take_over_control' er slået til (kalder 'homeassistant.update_entity' hvert 'interval'!)",
"transition": "Overgangsperiode når en ændring i lyset udføres (i sekunder)"
}
}
},
"error": {
"option_error": "Ugyldig indstilling"
}
}
}

View file

@ -46,4 +46,4 @@
"option_error": "Fehlerhafte Option"
}
}
}
}

View file

@ -3,42 +3,43 @@
"config": {
"step": {
"user": {
"title": "Choose a name for the Adaptive Lighting",
"description": "Every instance can contain multiple lights!",
"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!",
"data": {
"name": "Name"
}
}
},
"abort": {
"already_configured": "Device is already configured"
"already_configured": "This device is already configured"
}
},
"options": {
"step": {
"init": {
"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.",
"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 go 'off' to 'on' or when 'sleep_state' changes",
"interval": "interval, time between switch updates in seconds",
"max_brightness": "max_brightness, in %",
"max_color_temp": "max_color_temp, in Kelvin",
"min_brightness": "min_brightness, in %",
"min_color_temp": "min_color_temp, in Kelvin",
"only_once": "only_once, only adapt the lights when turning them on",
"prefer_rgb_color": "prefer_rgb_color, use 'rgb_color' over 'color_temp' when possible",
"separate_turn_on_commands": "separate_turn_on_commands, for each attribute (color, brightness, etc.) in 'light.turn_on', required for some lights.",
"sleep_brightness": "sleep_brightness, in %",
"sleep_color_temp": "sleep_color_temp, in Kelvin",
"sunrise_offset": "sunrise_offset, in +/- seconds",
"sunrise_time": "sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location)",
"sunset_offset": "sunset_offset, in +/- seconds",
"sunset_time": "sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)",
"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, in seconds"
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)",
"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).",
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
"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)",
"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 sunrise 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)"
}
}
},

View file

@ -0,0 +1,49 @@
{
"title": "Kohanduv valgus",
"config": {
"step": {
"user": {
"title": "Vali kohanduva valguse üksuse nimi",
"description": "Igas üksuses võib olla mitu valgustit!",
"data": {
"name": "Nimi"
}
}
},
"abort": {
"already_configured": "Üksus on juba seadistatud"
}
},
"options": {
"step": {
"init": {
"title": "Kohanduva valguse suvandid",
"description": "Kohanduva valguse suvandid. Valikute nimetused ühtuvad YAML kirjes olevatega. Valikuid ei kuvata kui seadistus on tehtud YAML kirjes.",
"data": {
"lights": "valgustid",
"initial_transition": "Algne üleminek kui valgustid lülituvad sisse/välja või unerežiim muutub",
"interval": "Intervall, aeg muutuste vahel sekundites",
"max_brightness": "Suurim heledus %",
"max_color_temp": "Suurim värvustemperatuur Kelvinites",
"min_brightness": "Vähim heledus %",
"min_color_temp": "Vähim värvustemperatuur Kelvinites",
"only_once": "Ainult üks kord, rakendub ainult valgusti sisselülitamisel",
"prefer_rgb_color": "Eelista RGB värve, võimalusel kasuta RGB sätteid värvustemperatuuri asemel",
"separate_turn_on_commands": "Eraldi lülitused iga valiku (värvus, heledus jne.) sisselülitamiseks, mõned valgustid vajavad seda.",
"sleep_brightness": "Unerežiimi heledus %",
"sleep_color_temp": "Uneržiimi värvus Kelvinites",
"sunrise_offset": "Nihe päikesetõusust, +/- sekundit",
"sunrise_time": "Päikesetõusu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)",
"sunset_offset": "Nihe päikeseloojangust, +/- sekundit",
"sunset_time": "Päikeseloojangu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)",
"take_over_control": "Käsitsi juhtimine: kui miski peale kohanduva valguse enda lültiab valgusti sisse ja see juba põleb, katkesta kohandamine kuni järgmise välise lülitamiseni.",
"detect_non_ha_changes": "Märka väliseid lülitusi: kui mõni säte muutub üle 10% (isegi väljaspoolt HA juhituna) siis peab käsitsi juhtimine olema lubatud (kutsutakse 'homeassistant.update_entity')'interval'!)",
"transition": "Üleminekud, sekundites"
}
}
},
"error": {
"option_error": "Vigane suvand"
}
}
}

View file

@ -0,0 +1,50 @@
{
"title": "Éclairage adaptatif",
"config": {
"step": {
"user": {
"title": "Choisissez un nom pour cette instance d'éclairage adaptatif",
"description": "Choisissez un nom pour cette instance. Vous pouvez configurer plusieurs instances d'éclairage adaptatif, chacune pouvant contrôler plusieurs lampes !",
"data": {
"name": "Nom"
}
}
},
"abort": {
"already_configured": "Cet appareil est déjà configuré"
}
},
"options": {
"step": {
"init": {
"title": "Options d'éclairage adaptatif",
"description": "Tous les paramètres de l'instance d'éclairage adaptatif. Les noms des options correspondent aux paramètres YAML. Aucune option n'est affichée si l'entrée adaptive_lighting est définie dans votre configuration YAML.",
"data": {
"lights": "lights : Les lampes à contrôler",
"initial_transition": "initial_transition : Transition (en secondes) lorsque l'état d'une lampe passe d'« éteinte » à « allumée ».",
"sleep_transition": "sleep_transition : Transition (en secondes) lorsque « sleep_state » est commuté.",
"interval": "interval : Temps (en secondes) entre deux mises à jour du commutateur.",
"max_brightness": "max_brightness : Luminosité maximale des lampes (en pourcentage) au cours d'un cycle.",
"max_color_temp": "max_color_temp : Couleur la plus froide (en kelvins) du cycle de température de couleur.",
"min_brightness": "min_brightness : Luminosité minimale des lampes (en pourcentage) au cours d'un cycle.",
"min_color_temp": "min_color_temp : Couleur la plus chaude (en kelvins) du cycle de température de couleur.",
"only_once": "only_once : Adapter les lampes uniquement au moment où elles sont allumées.",
"prefer_rgb_color": "prefer_rgb_color : Utiliser « rgb_color » plutôt que « color_temp » lorsque cela est possible.",
"separate_turn_on_commands": "separate_turn_on_commands : Séparer les commandes pour chaque attribut (couleur, luminosité, etc.) de « light.turn_on » (nécessaire pour certaines lampes).",
"sleep_brightness": "sleep_brightness : Luminosité (en pourcentage) du mode nuit.",
"sleep_color_temp": "sleep_color_temp : Température de couleur (en kelvins) du mode nuit.",
"sunrise_offset": "sunrise_offset : Décalage (en secondes [- : passé, + : futur]) du cycle par rapport au lever du soleil.",
"sunrise_time": "sunrise_time : Heure (HH:MM:SS) du lever du soleil. Si « None », utilise l'heure correspondant à votre emplacement.",
"sunset_offset": "sunset_offset : Décalage (en secondes [- : passé, + : futur]) du cycle par rapport au coucher du soleil.",
"sunset_time": "sunset_time : Heure (HH:MM:SS) du coucher du soleil. Si « None », utilise l'heure correspondant à votre emplacement.",
"take_over_control": "take_over_control : Si quelque chose d'autre que l'éclairage adaptatif appelle « light.turn_on » alors qu'une lampe est déjà allumée, cesser d'adapter cette lampe jusqu'à ce qu'elle (ou le commutateur) soit éteinte puis rallumée.",
"detect_non_ha_changes": "detect_non_ha_changes : Détecter tout changement de plus de 10 % appliqué aux lampes (même en dehors de HA). Nécessite que « take_over_control » soit activé. (Appelle « homeassistant.update_entity » tous les « interval » !)",
"transition": "transition : Durée de la transition (en secondes) des changements appliqués aux lampes."
}
}
},
"error": {
"option_error": "Option non valide"
}
}
}

View file

@ -0,0 +1,49 @@
{
"title":"Adaptiv Belysning",
"config":{
"step":{
"user":{
"title":"Velg et navn",
"description":"Velg et navn for denne konfigurasjonen for adaptiv belysning - hver konfigurasjon kan inneholde flere lyskilder!",
"data":{
"name":"Navn"
}
}
},
"abort":{
"already_configured":"Denne enheten er allerede konfigurert!"
}
},
"options":{
"step":{
"init":{
"title":"Adaptiv Belysning Innstillinger",
"description":"Alle innstillinger for en adaptiv belysning konfigurasjon. Innstillingene er identiske med innstillingene for YAML konfigurasjon. Ingen innstillinger vises dersom du har definert adaptive_lighting i din YAML konfigurasjon.",
"data":{
"lights":"Lys / Lyskilder",
"initial_transition":"'initial_transition': overgangen (i sekunder) når lysene skrus av eller på - eller når 'sleep_state' endres",
"interval":"'interval': tiden mellom oppdateringer (i sekunder)",
"max_brightness":"'max_brightness': den høyeste lysstyrken (i prosent) på lysene i løpet av en syklus",
"max_color_temp":"'max_color_temp': den høyeste fargetemperaturen (i kelvin) på lysene i løpet av en syklus",
"min_brightness":"'min_brightness': den laveste lysstyrken (i prosent) på lysene i løpet av en syklus",
"min_color_temp":"'min_color_temp': den laveste fargetemperaturen (i kelvin) på lysene i løpet av en syklus",
"only_once":"'only_once': anvend innstillingene for adaptiv belysning kun når lysene skrus av eller på",
"prefer_rgb_color":"'prefer_rgb_color': benytt rgb i stedet for fargetemperatur dersom det er mulig",
"separate_turn_on_commands":"'separate_turn_on_commands': separer kommandone i 'light.turn_on' for hver attributt (farge, lysstyrke, osv.). Dette kan være nødvendig for enkelte typer lys / lyskilder",
"sleep_brightness":"'sleep_brightness': lysstyrken på lysene (i prosent) når 'sleep_mode' (søvnmodus) er aktiv",
"sleep_color_temp":"'sleep_color_temp': fargetemperaturen på lysene (i kelvin) når 'sleep_mode' (søvnmodus) er aktiv",
"sunrise_offset":"'sunrise_offset': hvor lenge før (-) eller etter (+) tidspunktet solen står opp (lokalt) skal defineres som soloppgang (i sekunder)",
"sunrise_time":"'sunrise_time': definer tidspunktet for soloppgang manuelt (i følgende format: TT:MM:SS)",
"sunset_offset":"'sunset_offset': hvor lenge før (-) eller etter (+) tidspunktet solen går ned (lokalt) skal defineres som solnedgang (i sekunder)",
"sunset_time":"'sunset_time': definer tidspunktet for solnedgang manuelt (i følgende format: TT:MM:SS - f. eks: '20:30:00' vil definere tidspunktet for solnegang som halv-ni på kvelden)",
"take_over_control":"'take_over_control': dersom en annen tjeneste enn adaptiv belysning skrur lysene av eller på, vil automatisk adaptering av lyset stoppes inntil lyset (eller den tilhørende bryteren for adaptiv belysning) blir slått av - og på igjen",
"detect_non_ha_changes":"'detect_non_ha_changes': registrerer alle endringer i lysstyrke over 10% med opprinnelse utenfor Home Assistant - krever at 'take_over_control' er aktivert (OBS: tilkaller 'homeassistant.update_entity' ved hvert 'interval'!)",
"transition":"'transition': varigheten (i sekunder) på overgangen når lysene oppdateres "
}
}
},
"error":{
"option_error":"En eller flere valgte innstillinger er ugyldige"
}
}
}

View file

@ -0,0 +1,50 @@
{
"title": "Adaptacyjne oświetlenie",
"config": {
"step": {
"user": {
"title": "Wybierz nazwę grupy dla Adaptacyjnego oświetlenia",
"description": "Wybierz nazwę dla grupy. Możesz użyć wiele grup Adaptacyjnego oświetlenia, każda może mieć dowolną konfigurację świateł!",
"data": {
"name": "Nazwa"
}
}
},
"abort": {
"already_configured": "Już skonfigurowane!"
}
},
"options": {
"step": {
"init": {
"title": "Adaptacyjne oświetlenie opcje",
"description": "Wszystkie ustawienia dla Adaptacyjnego oświetlenia. Nazwy opcji odpowiadają ustawieniom YAML. Żadne opcje nie są wyświetlane, jeśli masz wpis adaptive_lighting zdefiniowany w konfiguracji YAML.",
"data": {
"lights": "światła",
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (sekund)",
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (sekund)",
"interval": "interval: Time between switch updates. (sekund)",
"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).",
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
"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 (+/- sekund)",
"sunrise_time": "sunrise_time: Manual override of the 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 (+/- sekund)",
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunrise 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 (sekund)"
}
}
},
"error": {
"option_error": "Błędne opcje"
}
}
}

View file

@ -0,0 +1,50 @@
{
"title": "Iluminação Adaptativa",
"config": {
"step": {
"user": {
"title": "Escolha um nome para a instância da Iluminação Adaptativa",
"description": "Escolha um nome para esta instância. Você pode executar várias instâncias de iluminação adaptativa, cada uma delas pode conter várias luzes!",
"data": {
"name": "Nome"
}
}
},
"abort": {
"already_configured": "Este dispositivo já está configurado"
}
},
"options": {
"step": {
"init": {
"title": "Opções da iluminação adaptiva",
"description": "Todas as configurações de um componente de iluminação adaptativa. Os nomes das opções correspondem às configurações de YAML. Nenhuma opção será exibida se você tiver a entrada adaptive_lighting definida em sua configuração YAML.",
"data": {
"lights": "luzes",
"initial_transition": "initial_transition: Quando as luzes mudam de 'off' para 'on'. (segundos)",
"sleep_transition": "sleep_transition: Quando 'sleep_state' muda. (segundos)",
"interval": "interval: Tempo entre as atualizações do switch. (segundos)",
"max_brightness": "max_brightness: Maior brilho das luzes durante um ciclo. (%)",
"max_color_temp": "max_color_temp: Matiz mais frio do ciclo de temperatura de cor. (Kelvin)",
"min_brightness": "min_brightness: Menor brilho das luzes durante um ciclo. (%)",
"min_color_temp": "min_color_temp, matiz mais quente do ciclo de temperatura de cor. (Kelvin)",
"only_once": "only_once: Apenas adapte as luzes ao ligá-las.",
"prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' em vez de 'color_temp' quando possível.",
"separate_turn_on_commands": "separar_turn_on_commands: Separe os comandos para cada atributo (cor, brilho, etc.) em 'light.turn_on' (necessário para algumas luzes).",
"sleep_brightness": "sleep_brightness, configuração de brilho para o modo de suspensão. (%)",
"sleep_color_temp": "sleep_color_temp: configuração de temperatura de cor para o modo de suspensão. (Kelvin)",
"sunrise_offset": "sunrise_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto do nascer do sol do ciclo (+/- segundos)",
"sunrise_time": "sunrise_time: substituição manual do horário do nascer do sol, se 'Nenhum', ele usa o horário real do nascer do sol em sua localização (HH:MM:SS)",
"sunset_offset": "Sunset_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto de pôr do sol do ciclo (+/- segundos)",
"sunset_time": "sunset_time: substituição manual do horário do pôr do sol, se 'Nenhum', ele usa o horário real do nascer do sol em sua localização (HH:MM:SS)",
"take_over_control": "take_over_control: Se qualquer coisa, exceto Adaptive Lighting, chamar 'light.turn_on' quando uma luz já estiver acesa, pare de adaptar essa luz até que ela (ou o interruptor) desligue -> ligue.",
"detect_non_ha_changes": "detect_non_ha_changes: detecta todas as alterações > 10% feitas nas luzes (também fora do HA), requer que 'take_over_control' seja ativado (chama 'homeassistant.update_entity' a cada 'intervalo'!)",
"transition": "Tempo de transição ao aplicar uma mudança nas luzes (segundos)"
}
}
},
"error": {
"option_error": "Opção inválida"
}
}
}

View file

@ -0,0 +1,49 @@
{
"title": "Адаптивне освітлення",
"config": {
"step": {
"user": {
"title": "Оберіть ім’я для екземпляра адаптивного освітлення",
"description": "Оберіть ім’я для цього екземпляра. Ви можете мати декілька екземплярів адаптивного освітлення, кожен може містити декілька приладів!",
"data": {
"name": "Ім’я"
}
}
},
"abort": {
"already_configured": "Цей пристрій вже налаштовано"
}
},
"options": {
"step": {
"init": {
"title": "Опції адаптивного освітлення",
"description": "Всі налаштування компонента адаптивного освітлення. Назви опцій відповідають налаштуванням у YAML. Опції не відображаються, якщо ви вже визначили їх у компоненті adaptive_lighting вашої YAML-конфігурації.",
"data": {
"lights": "прилади",
"initial_transition": "initial_transition: Коли прилад вимикається (off), вмикається (on), або змінює 'sleep_state'. (секунди)",
"interval": "interval: Час між оновленнями перемикача. (секунди)",
"max_brightness": "max_brightness: Найвища яскравість світла під час циклу. (%)",
"max_color_temp": "max_color_temp: Найхолодніший відтінок циклу кольорової температури. (Кельвін)",
"min_brightness": "min_brightness: Найнижча яскравість світла під час циклу. (%)",
"min_color_temp": "min_color_temp: Найтепліший відтінок циклу кольорової температури. (%)",
"only_once": "only_once: Адаптувати світло лише після початкового увімкнення.",
"prefer_rgb_color": "prefer_rgb_color: Використовувати 'rgb_color' замість 'color_temp', коли можливо.",
"separate_turn_on_commands": "separate_turn_on_commands: Окремі команди для кожного атрибута (колір, яскравість, тощо.) в 'light.turn_on' (необхідні для деяких приладів).",
"sleep_brightness": "sleep_brightness: Налаштування яскравості для Режиму сну. (%)",
"sleep_color_temp": "sleep_color_temp: Температура кольору для Режиму сну. (Кельвін)",
"sunrise_offset": "sunrise_offset: Як за довго до(-) або після(+) визначати точку сходу сонця для циклу (+/- секунд)",
"sunrise_time": "sunrise_time: Ручний перезапис часу сходу сонця, якщо 'None', тоді використовується час сходу сонця у вашій локації (HH:MM:SS)",
"sunset_offset": "sunset_offset: Як за довго до(-) або після(+) визначати точку заходу сонця для циклу (+/- секунд)",
"sunset_time": "sunset_time: Ручний перезапис часу заходу сонця, якщо 'None', тоді використовується час заходу сонця у вашій локації (HH:MM:SS)",
"take_over_control": "take_over_control: Якщо що-небудь, окрім Адаптивного освітлення, викликає 'light.turn_on', коли світло вже увімкнено, чи адаптувати освітлення допоки світло (або перемикач) перемкнеться (off -> on).",
"detect_non_ha_changes": "detect_non_ha_changes: виявляти всі зміни >10% до освітлення (включаючи ті, що зроблені поза HA), вимагає, щоб 'take_over_control' був включений (виклик 'homeassistant.update_entity' кожного оновлення 'interval'!)",
"transition": "Час переходу, який застосовується до освітлення (секунди)"
}
}
},
"error": {
"option_error": "Хибна опція"
}
}
}

View file

@ -1,5 +1,4 @@
{
"name": "adaptive_lighting",
"render_readme": true,
"domains": ["switch"]
"name": "Adaptive Lighting",
"render_readme": true
}

View file

@ -1,7 +0,0 @@
## Stay healthier and sleep better by syncing your lights with natural daylight to maintain your circadian rhythm!
<img src="https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/f/5fe7a780e9f8905fea4d1cbb66cdbe35858a6e36.jpg" width="690px">
Circadian Lighting slowly synchronizes your color changing lights with the regular naturally occurring color temperature of the sky throughout the day. This gives your environment a more natural feel, with cooler hues during the midday and warmer tints near twilight and dawn.
In addition, Circadian Lighting can set your lights to a nice cool white at 1% in “Sleep” mode, which is far brighter than starlight but wont reset your circadian rhythm or break down too much rhodopsin in your eyes.

11
setup.cfg Normal file
View file

@ -0,0 +1,11 @@
[isort]
force_sort_within_sections=True
profile=black
[flake8]
ignore = E203, E266, W503
max-line-length = 100
max-complexity = 18
select = B,C,E,F,W,T4,B9
per-file-ignores =
code_example.py: E402, E501

30
test_dependencies.py Normal file
View file

@ -0,0 +1,30 @@
with open("core/requirements_test_all.txt") as f:
lines = f.readlines()
components = []
packages = []
deps = {}
for i, line in enumerate(lines):
line = line.strip()
if line.startswith("# homeassistant."):
component = line.split("# homeassistant.")[1]
components.append(component)
elif components and line:
packages.append(line)
else:
for component in components:
for package in packages:
deps.setdefault(component, []).append(package)
components = []
packages = []
required = [
"components.recorder",
"components.mqtt",
"components.zeroconf",
"components.http",
]
to_install = []
for r in required:
to_install.extend(deps[r])
print(" ".join(to_install))

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Tests for the Adaptive Lighting integration."""

131
tests/test_config_flow.py Normal file
View file

@ -0,0 +1,131 @@
"""Test Adaptive Lighting config flow."""
from homeassistant import data_entry_flow
from homeassistant.components.adaptive_lighting.const import (
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
DEFAULT_NAME,
DOMAIN,
NONE_STR,
VALIDATION_TUPLES,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME
from tests.common import MockConfigEntry
DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES}
async def test_flow_manual_configuration(hass):
"""Test that config flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["handler"] == "adaptive_lighting"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NAME: "living room"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "living room"
async def test_import_success(hass):
"""Test import step is successful."""
data = DEFAULT_DATA.copy()
data[CONF_NAME] = DEFAULT_NAME
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "import"},
data=data,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == DEFAULT_NAME
for key, value in data.items():
assert result["data"][key] == value
async def test_options(hass):
"""Test updating options."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME},
options={},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = NONE_STR
data[CONF_SUNSET_TIME] = NONE_STR
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
for key, value in data.items():
assert result["data"][key] == value
async def test_incorrect_options(hass):
"""Test updating incorrect options."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME},
options={},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = "yolo"
data[CONF_SUNSET_TIME] = "yolo"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
)
async def test_import_twice(hass):
"""Test importing twice."""
data = DEFAULT_DATA.copy()
data[CONF_NAME] = DEFAULT_NAME
for _ in range(2):
_ = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "import"},
data=data,
)
async def test_changing_options_when_using_yaml(hass):
"""Test changing options when using YAML."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME},
source=SOURCE_IMPORT,
options={},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={},
)

55
tests/test_init.py Normal file
View file

@ -0,0 +1,55 @@
"""Tests for Adaptive Lighting integration."""
from homeassistant.components import adaptive_lighting
from homeassistant.components.adaptive_lighting.const import (
DEFAULT_NAME,
UNDO_UPDATE_LISTENER,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_setup_with_config(hass):
"""Test that we import the config and setup the integration."""
config = {
adaptive_lighting.DOMAIN: {
adaptive_lighting.CONF_NAME: DEFAULT_NAME,
}
}
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, config)
assert adaptive_lighting.DOMAIN in hass.data
async def test_successful_config_entry(hass):
"""Test that Adaptive Lighting is configured successfully."""
entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == ConfigEntryState.LOADED
assert UNDO_UPDATE_LISTENER in hass.data[adaptive_lighting.DOMAIN][entry.entry_id]
async def test_unload_entry(hass):
"""Test removing Adaptive Lighting."""
entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state == ConfigEntryState.NOT_LOADED
assert adaptive_lighting.DOMAIN not in hass.data

871
tests/test_switch.py Normal file
View file

@ -0,0 +1,871 @@
"""Tests for Adaptive Lighting switches."""
# pylint: disable=protected-access
import asyncio
import datetime
import logging
from random import randint
from unittest.mock import patch
from homeassistant.components.adaptive_lighting.const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
ATTR_TURN_ON_OFF_LISTENER,
CONF_DETECT_NON_HA_CHANGES,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MIN_COLOR_TEMP,
CONF_PREFER_RGB_COLOR,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
CONF_TRANSITION,
CONF_TURN_ON_LIGHTS,
DEFAULT_MAX_BRIGHTNESS,
DEFAULT_NAME,
DEFAULT_SLEEP_BRIGHTNESS,
DEFAULT_SLEEP_COLOR_TEMP,
DOMAIN,
SERVICE_APPLY,
SERVICE_SET_MANUAL_CONTROL,
SLEEP_MODE_SWITCH,
UNDO_UPDATE_LISTENER,
)
from homeassistant.components.adaptive_lighting.switch import (
_attributes_have_changed,
_expand_light_groups,
color_difference_redmean,
create_context,
is_our_context,
)
from homeassistant.components.demo.light import DemoLight
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_TEMP,
ATTR_RGB_COLOR,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.light import SERVICE_TURN_OFF
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
import homeassistant.config as config_util
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
CONF_NAME,
CONF_PLATFORM,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context, State
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
import pytest
from tests.common import MockConfigEntry
from tests.components.demo.test_light import ENTITY_LIGHT
_LOGGER = logging.getLogger(__name__)
SUNRISE = datetime.datetime(
year=2020,
month=10,
day=17,
hour=6,
)
SUNSET = datetime.datetime(
year=2020,
month=10,
day=17,
hour=22,
)
LAT_LONG_TZS = [
(39, -1, "Europe/Madrid"),
(60, 50, "GMT"),
(55, 13, "Europe/Copenhagen"),
(52.379189, 4.899431, "Europe/Amsterdam"),
(32.87336, -117.22743, "US/Pacific"),
]
_SWITCH_FMT = f"{SWITCH_DOMAIN}.{DOMAIN}"
ENTITY_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}"
ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_sleep_mode_{DEFAULT_NAME}"
ENTITY_ADAPT_BRIGHTNESS_SWITCH = f"{_SWITCH_FMT}_adapt_brightness_{DEFAULT_NAME}"
ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_adapt_color_{DEFAULT_NAME}"
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
@pytest.fixture
def reset_time_zone():
"""Reset time zone."""
yield
dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE
async def setup_switch(hass, extra_data):
"""Create the switch entry."""
entry = MockConfigEntry(domain=DOMAIN, data={CONF_NAME: DEFAULT_NAME, **extra_data})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
return entry, switch
async def setup_lights(hass):
"""Set up 3 light entities using the 'test' platform."""
await async_setup_component(
hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}}
)
await hass.async_block_till_done()
platform = getattr(hass.components, "test.light")
while platform.ENTITIES:
# Make sure it is empty
platform.ENTITIES.pop()
lights = [
DemoLight(
unique_id="light_1",
name="Bed Light",
state=True,
ct=200,
),
DemoLight(
unique_id="light_2",
name="Ceiling Lights",
state=True,
ct=380,
),
DemoLight(
unique_id="light_3",
name="Kitchen Lights",
state=False,
hs_color=(345, 75),
ct=240,
),
]
for light in lights:
light.hass = hass
slug = light.name.lower().replace(" ", "_")
light.entity_id = f"light.{slug}"
await light.async_update_ha_state()
platform.ENTITIES.extend(lights)
platform.init()
assert await async_setup_component(
hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {CONF_PLATFORM: "test"}}
)
await hass.async_block_till_done()
assert all(hass.states.get(light.entity_id) is not None for light in lights)
return lights
async def setup_lights_and_switch(hass, extra_conf=None):
"""Create switch and demo lights."""
# Setup demo lights and turn on
lights_instances = await setup_lights(hass)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT},
blocking=True,
)
# Setup switch
lights = [
"light.bed_light",
"light.ceiling_lights",
]
assert all(hass.states.get(light) is not None for light in lights)
_, switch = await setup_switch(
hass,
{
CONF_LIGHTS: lights,
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_DETECT_NON_HA_CHANGES: True,
CONF_PREFER_RGB_COLOR: False,
CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp
**(extra_conf or {}),
},
)
await hass.async_block_till_done()
return switch, lights_instances
async def test_adaptive_lighting_switches(hass):
"""Test switches created for adaptive_lighting integration."""
entry, _ = await setup_switch(hass, {})
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 4
assert set(hass.states.async_entity_ids(SWITCH_DOMAIN)) == {
ENTITY_SWITCH,
ENTITY_SLEEP_MODE_SWITCH,
ENTITY_ADAPT_COLOR_SWITCH,
ENTITY_ADAPT_BRIGHTNESS_SWITCH,
}
assert ATTR_TURN_ON_OFF_LISTENER in hass.data[DOMAIN]
assert entry.entry_id in hass.data[DOMAIN]
assert len(hass.data[DOMAIN].keys()) == 2
data = hass.data[DOMAIN][entry.entry_id]
assert SLEEP_MODE_SWITCH in data
assert SWITCH_DOMAIN in data
assert ADAPT_COLOR_SWITCH in data
assert ADAPT_BRIGHTNESS_SWITCH in data
assert UNDO_UPDATE_LISTENER in data
assert len(data.keys()) == 5
@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS)
async def test_adaptive_lighting_time_zones_with_default_settings(
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
):
"""Test setting up the Adaptive Lighting switches with different timezones."""
await config_util.async_process_ha_core_config(
hass,
{"latitude": lat, "longitude": long, "time_zone": timezone},
)
_, switch = await setup_switch(hass, {})
# Shouldn't raise an exception ever
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test")
)
@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS)
async def test_adaptive_lighting_time_zones_and_sun_settings(
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
):
"""Test setting up the Adaptive Lighting switches with different timezones.
Also test the (sleep) brightness and color temperature settings.
"""
await config_util.async_process_ha_core_config(
hass,
{"latitude": lat, "longitude": long, "time_zone": timezone},
)
_, switch = await setup_switch(
hass,
{
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
},
)
context = switch.create_context("test") # needs to be passed to update method
min_color_temp = switch._sun_light_settings.min_color_temp
sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunset = sunset - datetime.timedelta(hours=1)
after_sunset = sunset + datetime.timedelta(hours=1)
sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunrise = sunrise - datetime.timedelta(hours=1)
after_sunrise = sunrise + datetime.timedelta(hours=1)
async def patch_time_and_update(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
# At sunset the brightness should be max and color_temp at the smallest value
await patch_time_and_update(sunset)
assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] == min_color_temp
# One hour before sunset the brightness should be max and color_temp
# not at the smallest value yet.
await patch_time_and_update(before_sunset)
assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] > min_color_temp
# One hour after sunset the brightness should be down
await patch_time_and_update(after_sunset)
assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] == min_color_temp
# At sunrise the brightness should be max and color_temp at the smallest value
await patch_time_and_update(sunrise)
assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] == min_color_temp
# One hour before sunrise the brightness should smaller than max
# and color_temp at the min value.
await patch_time_and_update(before_sunrise)
assert switch._settings[ATTR_BRIGHTNESS_PCT] < DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] == min_color_temp
# One hour after sunrise the brightness should be up
await patch_time_and_update(after_sunrise)
assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_MAX_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] > min_color_temp
# Turn on sleep mode which make the brightness and color_temp
# deterministic regardless of the time
await switch.sleep_mode_switch.async_turn_on()
await switch._update_attrs_and_maybe_adapt_lights(context=context)
assert switch._settings[ATTR_BRIGHTNESS_PCT] == DEFAULT_SLEEP_BRIGHTNESS
assert switch._settings["color_temp_kelvin"] == DEFAULT_SLEEP_COLOR_TEMP
async def test_light_settings(hass):
"""Test that light settings are correctly applied."""
switch, _ = await setup_lights_and_switch(hass)
lights = switch._lights
# 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()
light_states = [hass.states.get(light) for light in lights]
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] == round(
255 * switch._settings[ATTR_BRIGHTNESS_PCT] / 100
)
last_service_data = switch.turn_on_off_listener.last_service_data[
state.entity_id
]
assert state.attributes[ATTR_BRIGHTNESS] == last_service_data[ATTR_BRIGHTNESS]
assert state.attributes[ATTR_COLOR_TEMP] == last_service_data[ATTR_COLOR_TEMP]
# Turn off "sleep mode"
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH},
blocking=True,
)
await hass.async_block_till_done()
# Test with different times
sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunset = sunset - datetime.timedelta(hours=1)
after_sunset = sunset + datetime.timedelta(hours=1)
sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunrise = sunrise - datetime.timedelta(hours=1)
after_sunrise = sunrise + datetime.timedelta(hours=1)
context = switch.create_context("test") # needs to be passed to update method
async def patch_time_and_get_updated_states(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
await switch._update_attrs_and_maybe_adapt_lights(
transition=0, context=context, force=True
)
await hass.async_block_till_done()
return [hass.states.get(light) for light in lights]
def assert_expected_color_temp(state):
last_service_data = switch.turn_on_off_listener.last_service_data[
state.entity_id
]
assert state.attributes[ATTR_COLOR_TEMP] == last_service_data[ATTR_COLOR_TEMP]
# At sunset the brightness should be max and color_temp at the smallest value
light_states = await patch_time_and_get_updated_states(sunset)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] == 255
assert_expected_color_temp(state)
# One hour before sunset the brightness should be max and color_temp
# not at the smallest value yet.
light_states = await patch_time_and_get_updated_states(before_sunset)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] == 255
assert_expected_color_temp(state)
# One hour after sunset the brightness should be down
light_states = await patch_time_and_get_updated_states(after_sunset)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] < 255
assert_expected_color_temp(state)
# At sunrise the brightness should be max and color_temp at the smallest value
light_states = await patch_time_and_get_updated_states(sunrise)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] == 255
assert_expected_color_temp(state)
# One hour before sunrise the brightness should smaller than max
# and color_temp at the min value.
light_states = await patch_time_and_get_updated_states(before_sunrise)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] < 255
assert_expected_color_temp(state)
# One hour after sunrise the brightness should be up
light_states = await patch_time_and_get_updated_states(after_sunrise)
for state in light_states:
assert state.attributes[ATTR_BRIGHTNESS] == 255
assert_expected_color_temp(state)
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)
light = "light.kitchen_lights"
assert light not in switch._lights
for state in [True, False]:
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON if state else SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: light},
blocking=True,
)
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test")
)
await hass.async_block_till_done()
assert light not in switch.turn_on_off_listener.lights
async def test_manual_control(hass):
"""Test the 'manual control' tracking."""
switch, (light, *_) = await setup_lights_and_switch(hass)
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: ENTITY_LIGHT, **kwargs},
blocking=True,
)
await hass.async_block_till_done()
await update()
_LOGGER.debug("Turn light %s, to %s", state, kwargs)
async def turn_switch(state, entity_id):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON if state else SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
await hass.async_block_till_done()
async def change_manual_control(set_to, extra_service_data=None):
if extra_service_data is None:
extra_service_data = {CONF_LIGHTS: [ENTITY_LIGHT]}
await hass.services.async_call(
DOMAIN,
SERVICE_SET_MANUAL_CONTROL,
{
ATTR_ENTITY_ID: switch.entity_id,
CONF_MANUAL_CONTROL: set_to,
**extra_service_data,
},
blocking=True,
)
await hass.async_block_till_done()
await update()
def increased_brightness():
return (light._brightness + 100) % 255
def increased_color_temp():
return max((light._ct + 100) % light.max_mireds, light.min_mireds)
# Nothing is manually controlled
await update()
assert not manual_control[ENTITY_LIGHT]
# Call light.turn_on for ENTITY_LIGHT
await turn_light(True, brightness=increased_brightness())
# Check that ENTITY_LIGHT is manually controlled
assert manual_control[ENTITY_LIGHT]
# Test adaptive_lighting.set_manual_control
await change_manual_control(False)
# Check that ENTITY_LIGHT is not manually controlled
assert not manual_control[ENTITY_LIGHT]
# Check that toggling light off to on resets manual control
await change_manual_control(True)
assert manual_control[ENTITY_LIGHT]
await turn_light(False)
await turn_light(True, brightness=increased_brightness())
assert hass.states.get(ENTITY_LIGHT).state == STATE_ON
assert not manual_control[ENTITY_LIGHT], manual_control
# Check that toggling (sleep mode) switch resets manual control
for entity_id in [ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH]:
await change_manual_control(True)
assert manual_control[ENTITY_LIGHT]
await turn_switch(False, entity_id)
await turn_switch(True, entity_id)
assert not 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)
assert not manual_control[ENTITY_LIGHT]
await switch.adapt_brightness_switch.async_turn_off()
await turn_light(True, brightness=increased_brightness())
assert not manual_control[ENTITY_LIGHT]
await turn_light(True, color_temp=(light._ct + 100) % 500)
assert manual_control[ENTITY_LIGHT]
await switch.adapt_brightness_switch.async_turn_on() # turn on again
# Check that when 'adapt_color' is off, changing the color
# doesn't mark it as manually controlled but changing brightness
# does
await turn_light(False) # reset manually controlled status
await turn_light(True)
assert not manual_control[ENTITY_LIGHT]
await switch.adapt_color_switch.async_turn_off()
await turn_light(True, color_temp=increased_color_temp())
assert not manual_control[ENTITY_LIGHT]
await turn_light(True, brightness=increased_brightness())
assert manual_control[ENTITY_LIGHT]
# Check that when 'adapt_color' adapt_brightness are both off
# nothing marks it as manually controlled
await turn_light(False) # reset manually controlled status
await turn_light(True)
await switch.adapt_color_switch.async_turn_off()
await switch.adapt_brightness_switch.async_turn_off()
assert not manual_control[ENTITY_LIGHT]
await turn_light(True, color_temp=increased_color_temp())
await turn_light(True, brightness=increased_brightness())
await turn_light(
True,
color_temp=increased_color_temp(),
brightness=increased_brightness(),
)
assert not manual_control[ENTITY_LIGHT]
# Turn switches on again
await switch.adapt_color_switch.async_turn_on()
await switch.adapt_brightness_switch.async_turn_on()
# Check that when no lights are specified, all are reset
await change_manual_control(True, {CONF_LIGHTS: switch._lights})
assert all([manual_control[eid] for eid in switch._lights])
# do not pass "lights" so reset all
await change_manual_control(False, {})
assert all([not manual_control[eid] for eid in switch._lights])
async def test_apply_service(hass):
"""Test adaptive_lighting.apply service."""
switch, (_, _, light) = await setup_lights_and_switch(hass)
entity_id = light.entity_id
assert entity_id not in switch._lights
def increased_brightness():
return (light._brightness + 100) % 255
def increased_color_temp():
return max((light._ct + 100) % light.max_mireds, light.min_mireds)
async def change_light():
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: entity_id,
ATTR_BRIGHTNESS: increased_brightness(),
ATTR_COLOR_TEMP: increased_color_temp(),
},
blocking=True,
)
await hass.async_block_till_done()
async def apply(**kwargs):
await hass.services.async_call(
DOMAIN,
SERVICE_APPLY,
{
ATTR_ENTITY_ID: ENTITY_SWITCH,
CONF_LIGHTS: [entity_id],
CONF_TURN_ON_LIGHTS: True,
**kwargs,
},
blocking=True,
)
await hass.async_block_till_done()
# Test turn on with defaults
assert hass.states.get(entity_id).state == STATE_OFF
await apply()
assert hass.states.get(entity_id).state == STATE_ON
await change_light()
# Test only changing color
old_state = hass.states.get(entity_id).attributes
await apply(adapt_color=True, adapt_brightness=False)
new_state = hass.states.get(entity_id).attributes
assert old_state[ATTR_BRIGHTNESS] == new_state[ATTR_BRIGHTNESS]
assert old_state[ATTR_COLOR_TEMP] != new_state[ATTR_COLOR_TEMP]
# Test only changing brightness
await change_light()
old_state = hass.states.get(entity_id).attributes
await apply(adapt_color=False, adapt_brightness=True)
new_state = hass.states.get(entity_id).attributes
assert old_state[ATTR_BRIGHTNESS] != new_state[ATTR_BRIGHTNESS]
assert old_state[ATTR_COLOR_TEMP] == new_state[ATTR_COLOR_TEMP]
async def test_switch_off_on_off(hass):
"""Test switch rapid off_on_off."""
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():
await switch._update_attrs_and_maybe_adapt_lights(
transition=0, context=switch.create_context("test")
)
await hass.async_block_till_done()
switch, _ = await setup_lights_and_switch(hass)
for turn_light_state_at_end in [True, False]:
# Turn light on
await turn_light(True)
# Turn light off with transition
await turn_light(False, transition=1)
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# Set state to on after a second (like happens IRL)
await asyncio.sleep(1e-3)
hass.states.async_set(ENTITY_LIGHT, STATE_ON)
# Set state to off after a second (like happens IRL)
await asyncio.sleep(1e-3)
hass.states.async_set(ENTITY_LIGHT, STATE_OFF)
# Now we test whether the sleep task is there
assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks
sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT]
assert not sleep_task.cancelled()
# A 'light.turn_on' event should cancel that task
await turn_light(turn_light_state_at_end)
await update()
state = hass.states.get(ENTITY_LIGHT).state
if turn_light_state_at_end:
assert sleep_task.cancelled()
assert state == STATE_ON
else:
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]
def test_color_difference_redmean():
"""Test color_difference_redmean function."""
for _ in range(10):
rgb_1 = (randint(0, 255), randint(0, 255), randint(0, 255))
rgb_2 = (randint(0, 255), randint(0, 255), randint(0, 255))
color_difference_redmean(rgb_1, rgb_2)
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 = {ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0), ATTR_COLOR_TEMP: 100}
attributes_2 = {
ATTR_BRIGHTNESS: 100,
ATTR_RGB_COLOR: (255, 0, 0),
ATTR_COLOR_TEMP: 300,
}
kwargs = dict(
light="light.test",
adapt_brightness=True,
adapt_color=True,
context=Context(),
)
assert not _attributes_have_changed(
old_attributes=attributes_1, new_attributes=attributes_1, **kwargs
)
for key, value in attributes_2.items():
attrs = dict(attributes_1)
attrs[key] = value
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: 100},
new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0)},
**kwargs,
)
async def test_unload_switch(hass):
"""Test removing Adaptive Lighting."""
entry, _ = await setup_switch(hass, {})
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert DOMAIN not in hass.data
@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF, None])
async def test_restore_off_state(hass, state):
"""Test that the 'off' and 'on' states are propoperly restored."""
with patch(
"homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state",
return_value=State(ENTITY_SWITCH, state) if state is not None else None,
):
await hass.async_start()
await hass.async_block_till_done()
_, switch = await setup_switch(hass, {})
if state == STATE_ON:
assert switch.is_on
elif state == STATE_OFF:
assert not switch.is_on
elif state is None:
assert switch.is_on
for _switch, initial_state in [
(switch.sleep_mode_switch, False),
(switch.adapt_brightness_switch, True),
(switch.adapt_color_switch, True),
]:
if state == STATE_ON:
assert _switch.is_on
elif state == STATE_OFF:
assert not _switch.is_on
elif state is None:
if initial_state:
assert _switch.is_on
else:
assert not _switch.is_on
@pytest.mark.xfail(reason="Offset is larger than half a day")
async def test_offset_too_large(hass):
"""Test that update fails when the offset is too large."""
_, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12})
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test")
)
await hass.async_block_till_done()
async def test_turn_on_and_off_when_already_at_that_state(hass):
"""Test 'switch.turn_on/off' when switch is on/off."""
_, switch = await setup_switch(hass, {})
await switch.async_turn_on()
await hass.async_block_till_done()
await switch.async_turn_on()
await hass.async_block_till_done()
await switch.async_turn_off()
await hass.async_block_till_done()
await switch.async_turn_off()
await hass.async_block_till_done()
async def test_async_update_at_interval(hass):
"""Test '_async_update_at_interval' method."""
_, switch = await setup_switch(hass, {})
await switch._async_update_at_interval()
@pytest.mark.parametrize("separate_turn_on_commands", (True, False))
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(
hass, {CONF_SEPARATE_TURN_ON_COMMANDS: separate_turn_on_commands}
)
# We just turn sleep mode on and off which should change the
# brightness and color. We don't test whether the number are exactly
# what we expect because we do this in other tests already, we merely
# check whether the brightness and color_temp change.
context = switch.create_context("test") # needs to be passed to update method
brightness = light.brightness
color_temp = light.color_temp
await switch.sleep_mode_switch.async_turn_on()
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
# TODO: figure out why `light.brightness` is not updating
attrs = hass.states.get(light.entity_id).attributes
sleep_brightness = attrs["brightness"]
sleep_color_temp = attrs["color_temp"]
assert sleep_brightness != brightness
assert sleep_color_temp != color_temp
await switch.sleep_mode_switch.async_turn_off()
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
attrs = hass.states.get(light.entity_id).attributes
brightness = attrs["brightness"]
color_temp = attrs["color_temp"]
assert sleep_brightness != brightness
assert sleep_color_temp != color_temp