├── .gitattributes ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── auto-comment.yml ├── ISSUE_TEMPLATE │ ├── feature.md │ ├── enhancement.md │ ├── doc.md │ ├── config.yml │ └── bug-report.md ├── release-drafter.yml ├── workflows │ ├── toc.yaml │ ├── pre-commit.yaml │ ├── hassfest.yaml │ ├── validate.yml │ ├── main-to-master-sync.yml │ ├── release-drafter.yml │ ├── install_dependencies │ │ └── action.yml │ ├── docker-build.yml │ ├── update-test-matrix.yaml │ ├── update-readme.yml │ ├── deploy-webapp.yml │ └── pytest.yaml ├── renovate.json ├── update-services.py ├── update-strings.py └── config.yml ├── webapp ├── requirements.txt.in ├── __init__.py ├── requirements-dev.txt ├── requirements.txt ├── README.md └── app.py ├── custom_components └── adaptive_lighting │ ├── translations │ ├── bn.json │ ├── zh_Hant.json │ ├── el.json │ ├── ja.json │ ├── hr.json │ ├── gl.json │ ├── af.json │ ├── et.json │ ├── ro.json │ ├── pt-BR.json │ ├── pt.json │ ├── LICENSE.md │ ├── sl.json │ ├── zh-Hans.json │ ├── ko.json │ └── da.json │ ├── manifest.json │ ├── helpers.py │ ├── __init__.py │ ├── _docs_helpers.py │ ├── hass_utils.py │ ├── config_flow.py │ ├── adaptation_utils.py │ └── services.yaml ├── tests ├── __init__.py ├── conftest.py ├── README.md ├── test_init.py ├── test_hass_utils.py ├── test_color_and_brightness.py ├── test_config_flow.py └── test_adaptation_utils.py ├── scripts ├── lint ├── setup-symlinks ├── setup-devcontainer ├── develop ├── setup-dependencies └── update-test-matrix.py ├── hacs.json ├── setup.cfg ├── .vscode ├── tasks.json └── settings.json ├── config └── configuration.yaml ├── .pre-commit-config.yaml ├── test_dependencies.py ├── .devcontainer.json ├── Dockerfile ├── .gitignore ├── .ruff.toml └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @basnijholt 2 | -------------------------------------------------------------------------------- /webapp/requirements.txt.in: -------------------------------------------------------------------------------- 1 | astral==2.2 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [basnijholt] 2 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/bn.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/zh_Hant.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /webapp/__init__.py: -------------------------------------------------------------------------------- 1 | """Shiny webapp for Adaptive Lighting.""" 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the Adaptive Lighting integration.""" 2 | -------------------------------------------------------------------------------- /webapp/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | astral==2.2 2 | matplotlib 3 | shinyswatch 4 | shiny 5 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | pre-commit run --all-files 8 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Adaptive Lighting", 3 | "render_readme": true, 4 | "homeassistant": "2024.12.0" 5 | } 6 | -------------------------------------------------------------------------------- /.github/auto-comment.yml: -------------------------------------------------------------------------------- 1 | # Comment to a new issue. 2 | # Disabled 3 | # issueOpened: "" 4 | 5 | # Disabled 6 | # pullRequestOpened: "" 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Suggest a new feature 4 | title: '' 5 | labels: kind/feature, need/triage 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | 6 | **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Suggest an improvement to an existing feature. 4 | title: '' 5 | labels: kind/enhancement, need/triage 6 | assignees: '' 7 | 8 | --- 9 | -------------------------------------------------------------------------------- /webapp/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile requirements.txt.in --output-file requirements.txt 3 | astral==2.2 4 | # via -r requirements.txt.in 5 | pytz==2023.3.post1 6 | # via astral 7 | -------------------------------------------------------------------------------- /.github/workflows/toc.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | name: TOC Generator 5 | jobs: 6 | generateTOC: 7 | name: TOC Generator 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: technote-space/toc-generator@v4 11 | with: 12 | TOC_TITLE: "" 13 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: actions/setup-python@v6 14 | - uses: pre-commit/action@v3.0.1 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | force_sort_within_sections=True 3 | profile=black 4 | 5 | [flake8] 6 | ignore = E203, E266, W503 7 | max-line-length = 100 8 | max-complexity = 18 9 | select = B,C,E,F,W,T4,B9 10 | per-file-ignores = 11 | code_example.py: E402, E501 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | asyncio_mode = auto 16 | -------------------------------------------------------------------------------- /webapp/README.md: -------------------------------------------------------------------------------- 1 | To run this app locally, install the requirements and run `shiny run`. 2 | 3 | ``` 4 | $ cd webapp 5 | $ pip install -r requirements-dev.txt 6 | $ shiny run 7 | ``` 8 | 9 | After the server starts, which should take only a moment, you can open 10 | [http://127.0.0.1:8000](http://127.0.0.1:8000) to see the interface. 11 | -------------------------------------------------------------------------------- /scripts/setup-symlinks: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | cd "$(dirname "$0")/.." 4 | 5 | # Link custom components 6 | cd core/homeassistant/components/ 7 | ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting 8 | cd - 9 | 10 | # Link tests 11 | cd core/tests/components/ 12 | ln -fs ../../../tests/ adaptive_lighting 13 | cd - 14 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | jobs: 11 | validate_hassfest: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v6.0.1" 15 | - uses: home-assistant/actions/hassfest@master 16 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | jobs: 11 | validate_hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v6" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/doc.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Issue 3 | about: Report missing, erroneous docs, broken links or propose new docs 4 | title: '' 5 | labels: kind/docs_issue, need/triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Location 11 | 12 | 13 | 14 | #### Description 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/main-to-master-sync.yml: -------------------------------------------------------------------------------- 1 | name: Sync Main to Master 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | sync: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v6 15 | with: 16 | ref: main 17 | fetch-depth: 0 18 | 19 | - name: Push changes to master 20 | run: | 21 | git checkout -b master 22 | git push origin +master 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Lint (run pre-commit hooks)", 12 | "type": "shell", 13 | "command": "scripts/lint", 14 | "problemMatcher": [] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scripts/setup-devcontainer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | cd "$(dirname "$0")/.." 4 | 5 | # Clone only if the folder doesn't exist 6 | if [[ ! -d "core" ]]; then 7 | git clone --depth 1 --branch dev https://github.com/home-assistant/core.git 8 | fi 9 | 10 | pip install \ 11 | colorlog \ 12 | pip \ 13 | ruff \ 14 | uv 15 | 16 | pip cache purge 17 | 18 | uv venv --clear --python 3.13 19 | ./scripts/setup-dependencies 20 | ./scripts/setup-symlinks 21 | uv run pre-commit install-hooks 22 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/el.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Adaptive Lighting", 3 | "options": { 4 | "step": { 5 | "init": { 6 | "title": "Επιλογές Adaptive Lighting" 7 | } 8 | } 9 | }, 10 | "services": { 11 | "change_switch_settings": { 12 | "fields": { 13 | "only_once": { 14 | "description": "Προσαρμογή των φώτων μόνο όταν είναι αναμμένα (`true`) ή συνεχής προσαρμογή αυτών (`false`). 🔄" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | update_release_draft: 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: release-drafter/release-drafter@v6 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "adaptive_lighting", 3 | "name": "Adaptive Lighting", 4 | "codeowners": ["@basnijholt", "@RubenKelevra", "@th3w1zard1", "@protyposis"], 5 | "config_flow": true, 6 | "dependencies": ["light"], 7 | "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", 8 | "iot_class": "calculated", 9 | "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", 10 | "requirements": ["ulid-transform"], 11 | "version": "1.29.0" 12 | } 13 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # https://www.home-assistant.io/integrations/default_config/ 2 | default_config: 3 | 4 | # https://www.home-assistant.io/integrations/logger/ 5 | logger: 6 | default: info 7 | logs: 8 | custom_components.adaptive_lighting: debug 9 | 10 | light: 11 | - platform: template 12 | lights: 13 | dummylight: 14 | friendly_name: "Dummy Light" 15 | turn_on: 16 | turn_off: 17 | set_level: 18 | set_temperature: 19 | supports_transition_template: "{{ true }}" 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: mixed-line-ending 9 | args: ["--fix=lf"] 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.11.13 12 | hooks: 13 | - id: ruff 14 | args: ["--fix"] 15 | - repo: https://github.com/psf/black 16 | rev: 25.1.0 17 | hooks: 18 | - id: black 19 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/adaptive_lighting 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | }, 5 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 6 | "python.testing.pytestEnabled": true, 7 | "python.testing.pytestArgs": [ 8 | "-vvv", 9 | "-qq", 10 | "--timeout=9", 11 | "--durations=10", 12 | "--cov=homeassistant", 13 | "--cov-report=xml", 14 | "-o", 15 | "console_output_style=count", 16 | "-p", 17 | "no:sugar", 18 | "core/tests/components/adaptive_lighting" 19 | ], 20 | "python.analysis.extraPaths": [ 21 | "${workspaceFolder}/core" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Getting Help on adaptive-lighting 4 | url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/q-a 5 | about: Q&A section of the discussion tab 6 | - name: Share your idea 7 | url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/ideas 8 | about: And discuss it with the community 9 | - name: General discussions about this component 10 | url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/general 11 | about: General discussions about this component 12 | - name: Share your setup with adaptive-lighting 13 | url: https://github.com/basnijholt/adaptive-lighting/discussions/categories/show-and-tell 14 | about: Or see what other people do with this component 15 | -------------------------------------------------------------------------------- /scripts/setup-dependencies: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | cd "$(dirname "$0")/.." 4 | 5 | # Remove mypy-dev from requirements_test.txt since the maintainer deletes old versions from PyPI. 6 | # We'll install the latest version separately below. 7 | # See: https://github.com/cdce8p/mypy-dev/issues/62 8 | sed -i '/^mypy-dev/d' core/requirements_test.txt 9 | 10 | uv pip install -r core/requirements.txt 11 | uv pip install -r core/requirements_test.txt 12 | uv pip install -e core/ 13 | uv pip install ulid-transform # this is in Adaptive-lighting's manifest.json 14 | uv pip install $(python test_dependencies.py) 15 | 16 | # Install the latest mypy-dev (not pinned since old versions get deleted from PyPI) 17 | uv pip install --upgrade mypy-dev 18 | 19 | # Workaround for aiodns/pycares compatibility issue 20 | # See: https://github.com/aio-libs/aiodns/issues/214 21 | uv pip install --upgrade aiodns 22 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "rebaseWhen": "behind-base-branch", 4 | "dependencyDashboard": true, 5 | "labels": [ 6 | "dependencies", 7 | "no-stale" 8 | ], 9 | "commitMessagePrefix": "⬆️", 10 | "commitMessageTopic": "{{depName}}", 11 | "prBodyDefinitions": { 12 | "Release": "yes" 13 | }, 14 | "packageRules": [ 15 | { 16 | "matchManagers": [ 17 | "github-actions" 18 | ], 19 | "addLabels": [ 20 | "github_actions" 21 | ], 22 | "rangeStrategy": "pin" 23 | }, 24 | { 25 | "matchManagers": [ 26 | "github-actions" 27 | ], 28 | "matchUpdateTypes": [ 29 | "minor", 30 | "patch" 31 | ], 32 | "automerge": true 33 | } 34 | ], 35 | "extends": [ 36 | "config:recommended" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration for adaptive-lighting tests.""" 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def mock_template_deprecation_issue(): 10 | """Mock the template deprecation issue creation. 11 | 12 | The template component's legacy platform syntax creates deprecation 13 | issues that require translations. Since adaptive-lighting tests use 14 | template lights as test fixtures (not testing the template integration 15 | itself), we mock the issue creation to avoid translation validation errors. 16 | """ 17 | # Patch the create_legacy_template_issue function in the template helpers 18 | # to be a no-op when called for the deprecated_legacy_templates issue 19 | try: 20 | with patch( 21 | "homeassistant.components.template.helpers.create_legacy_template_issue", 22 | ): 23 | yield 24 | except (ImportError, ModuleNotFoundError, AttributeError): 25 | # Older HA versions don't have this function 26 | yield 27 | -------------------------------------------------------------------------------- /.github/update-services.py: -------------------------------------------------------------------------------- 1 | """Creates a services.yaml file with the latest docs.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | import yaml 7 | 8 | sys.path.append(str(Path(__file__).parent.parent)) 9 | 10 | from custom_components.adaptive_lighting import const 11 | 12 | services_filename = Path("custom_components") / "adaptive_lighting" / "services.yaml" 13 | with open(services_filename) as f: # noqa: PTH123 14 | services = yaml.safe_load(f) 15 | 16 | for service_name, dct in services.items(): 17 | _docs = {"set_manual_control": const.DOCS_MANUAL_CONTROL, "apply": const.DOCS_APPLY} 18 | alternative_docs = _docs.get(service_name, const.DOCS) 19 | for field_name, field in dct["fields"].items(): 20 | description = alternative_docs.get(field_name, const.DOCS[field_name]) 21 | field["description"] = description 22 | 23 | comment = "# This file is auto-generated by .github/update-services.py." 24 | 25 | with services_filename.open("w") as f: 26 | f.write(comment + "\n") 27 | yaml.dump(services, f, sort_keys=False, width=1000, allow_unicode=True) 28 | -------------------------------------------------------------------------------- /.github/workflows/install_dependencies/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Install Dependencies' 2 | description: 'Install Home Assistant and test dependencies' 3 | inputs: 4 | python-version: 5 | description: 'Python version' 6 | required: true 7 | default: '3.10' 8 | core-version: 9 | description: 'Home Assistant core version' 10 | required: false 11 | default: 'dev' 12 | 13 | runs: 14 | using: "composite" 15 | steps: 16 | - name: Check out code from GitHub 17 | uses: actions/checkout@v6 18 | with: 19 | repository: ${{ github.repository }} 20 | ref: ${{ github.ref }} 21 | persist-credentials: false 22 | fetch-depth: 0 23 | - name: Check out code from GitHub 24 | uses: actions/checkout@v6 25 | with: 26 | repository: home-assistant/core 27 | path: core 28 | ref: ${{ inputs.core-version }} 29 | - name: Set up Python ${{ inputs.python-version }} 30 | id: python 31 | uses: actions/setup-python@v6.1.0 32 | with: 33 | python-version: ${{ inputs.python-version }} 34 | - name: Set up UV 35 | uses: astral-sh/setup-uv@v7 36 | - name: Install dependencies 37 | shell: bash 38 | run: | 39 | uv venv --python ${{ inputs.python-version }} 40 | ./scripts/setup-dependencies 41 | ./scripts/setup-symlinks 42 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "明るさの自動調整", 3 | "services": { 4 | "change_switch_settings": { 5 | "fields": { 6 | "sunrise_offset": { 7 | "description": "日の出時間を基準に秒単位で正値もしくは負値で調整する。⏰" 8 | }, 9 | "only_once": { 10 | "description": "一度だけ明るさを自動調整するには(`true`)、常に自動調整し続ける場合は(`false`)。" 11 | }, 12 | "sunset_offset": { 13 | "description": "日の入時間を基準に秒単位で正値もしくは負値で調整する。⏰" 14 | }, 15 | "entity_id": { 16 | "description": "スイッチのエンティティID。 📝" 17 | } 18 | } 19 | }, 20 | "apply": { 21 | "fields": { 22 | "lights": { 23 | "description": "適応する照明(か照明のリスト)の設定。 💡" 24 | } 25 | } 26 | } 27 | }, 28 | "options": { 29 | "step": { 30 | "init": { 31 | "data": { 32 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: 最初に照明オンにするとき。`true`を設定すると、AL(適応型照明)は調色や明るさを指定せずや`light.turn_on`をしたときのみ適応します。❌🌈 例えば、適応型照明をシーンを有効にするときにしないようにする。`false`であれば、`service_data`に最初から、ALはシーンの状態に関係なく調色や明るさを適応する。`take_over_control`を有効にすることが必要。🕵️ " 33 | }, 34 | "data_description": { 35 | "sunrise_offset": "日の出時間を基準に秒単位で正値もしくは負値で調整する。⏰", 36 | "sunset_offset": "日の入時間を基準に秒単位で正値もしくは負値で調整する。⏰" 37 | }, 38 | "title": "明るさの自動調整オプション" 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test_dependencies.py: -------------------------------------------------------------------------------- 1 | """Extracts the dependencies of the components required for testing.""" 2 | 3 | from collections import defaultdict 4 | from pathlib import Path 5 | 6 | deps = defaultdict(list) 7 | components, packages = [], [] 8 | 9 | requirements = Path("core") / "requirements_test_all.txt" 10 | 11 | with requirements.open() as f: 12 | lines = f.readlines() 13 | 14 | for line in lines: 15 | line = line.strip() # noqa: PLW2901 16 | 17 | if line.startswith("# homeassistant."): 18 | if components and packages: 19 | for component in components: 20 | deps[component].extend(packages) 21 | components, packages = [], [] 22 | components.append(line.split("# homeassistant.")[1]) 23 | elif components and line: 24 | packages.append(line) 25 | 26 | # The last batch of components and packages 27 | if components and packages: 28 | for component in components: 29 | deps[component].extend(packages) 30 | 31 | required = [ 32 | "components.recorder", 33 | "components.mqtt", 34 | "components.zeroconf", 35 | "components.http", 36 | "components.stream", 37 | "components.conversation", # only available after HA≥2023.2 38 | "components.cloud", 39 | "components.ffmpeg", # needed since 2024.1 40 | ] 41 | to_install = [package for r in required for package in deps[r]] 42 | to_install.append("flaky") 43 | 44 | print(" ".join(to_install)) # noqa: T201 45 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basnijholt/adaptive_lighting", 3 | "image": "mcr.microsoft.com/devcontainers/python:3-3.13", 4 | "postCreateCommand": "./scripts/setup-devcontainer && . .venv/bin/activate", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant", 11 | "onAutoForward": "notify" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "ms-python.python", 18 | "github.vscode-pull-request-github", 19 | "ryanluker.vscode-coverage-gutters", 20 | "ms-python.vscode-pylance" 21 | ], 22 | "settings": { 23 | "files.eol": "\n", 24 | "editor.tabSize": 4, 25 | "python.pythonPath": "/usr/bin/python3", 26 | "python.analysis.autoSearchPaths": false, 27 | "python.linting.pylintEnabled": true, 28 | "python.linting.enabled": true, 29 | "python.formatting.provider": "black", 30 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 31 | "editor.formatOnPaste": false, 32 | "editor.formatOnSave": true, 33 | "editor.formatOnType": true, 34 | "files.trimTrailingWhitespace": true 35 | } 36 | } 37 | }, 38 | "remoteUser": "vscode", 39 | "features": {} 40 | } 41 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "step": { 4 | "init": { 5 | "title": "Opcije prilagodljivog osvjetljenja", 6 | "data_description": { 7 | "sunrise_offset": "Podesite vrijeme izlaska sunca s pozitivnim ili negativnim pomakom u sekundama. ⏰", 8 | "sunset_offset": "Podesite vrijeme izlaska sunca s pozitivnim ili negativnim pomakom u sekundama. ⏰" 9 | }, 10 | "data": { 11 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: Prilikom početnog paljenja svjetla. Ako je postavljeno na \"true\", AL se prilagođava samo ako se \"light.turn_on\" pozove bez navođenja boje ili svjetline. ❌🌈 Ovo npr. sprječava prilagodbu prilikom aktiviranja scene. Ako je \"false\", AL se prilagođava bez obzira na prisutnost boje ili svjetline u početnim \"service_data\". Potrebno je omogućiti `take_over_control`. 🕵️ " 12 | } 13 | } 14 | } 15 | }, 16 | "title": "prilagodi svjetlinu", 17 | "services": { 18 | "change_switch_settings": { 19 | "fields": { 20 | "only_once": { 21 | "description": "Prilagodi svjetla samo kada su uključena (true) ili ih neprestano prilagođavaj (false). 🔄" 22 | }, 23 | "sunrise_offset": { 24 | "description": "Podesite vrijeme izlaska sunca s pozitivnim ili negativnim pomakom u sekundama. ⏰" 25 | }, 26 | "sunset_offset": { 27 | "description": "Podesite vrijeme izlaska sunca s pozitivnim ili negativnim pomakom u sekundama. ⏰" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ['v*'] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | strategy: 21 | matrix: 22 | platform: [linux/amd64, linux/arm64] 23 | steps: 24 | - uses: actions/checkout@v6 25 | - uses: docker/setup-qemu-action@v3 26 | - uses: docker/setup-buildx-action@v3 27 | - uses: docker/login-action@v3 28 | with: 29 | registry: ${{ env.REGISTRY }} 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | - id: meta 33 | uses: docker/metadata-action@v5 34 | with: 35 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 36 | tags: | 37 | type=ref,event=branch 38 | type=ref,event=pr 39 | type=semver,pattern={{version}} 40 | type=semver,pattern={{major}}.{{minor}} 41 | type=raw,value=latest,enable={{is_default_branch}} 42 | - uses: docker/build-push-action@v6 43 | with: 44 | context: . 45 | platforms: ${{ matrix.platform }} 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Developer notes for the tests directory 2 | 3 | To run the tests, check out the [CI configuration](../.github/workflows/pytest.yml) to see how they are executed in the CI pipeline. 4 | Alternatively, you can use the provided Docker image to run the tests locally or run them with VS Code directly in the dev container. 5 | 6 | To run the tests using the Docker image, navigate to the `adaptive-lighting` repo folder and execute the following command: 7 | 8 | Linux / MacOS / Windows PowerShell: 9 | ```bash 10 | docker run -v ${PWD}:/app basnijholt/adaptive-lighting:latest 11 | ``` 12 | 13 | - In windows command prompt, the command is: 14 | ```bash 15 | docker run -v %cd%:/app basnijholt/adaptive-lighting:latest 16 | ``` 17 | 18 | This command will download the Docker image from [the adaptive-lighting Docker Hub repo](https://hub.docker.com/r/basnijholt/adaptive-lighting) and run the tests. 19 | 20 | If you prefer to build the image yourself, use the following command: 21 | 22 | ```bash 23 | docker build -t basnijholt/adaptive-lighting:latest --no-cache --progress=plain . 24 | ``` 25 | 26 | This might be necessary if the image on Docker Hub is outdated or if the [`test_dependencies.py`](../test_dependencies.py) file is updated. 27 | 28 | ## Passing arguments to pytest 29 | 30 | You can pass arguments to pytest by appending them to the command: 31 | 32 | For example, to run the tests with a custom log format, use the following command (this also gets rid of the captured stderr output): 33 | 34 | ```bash 35 | docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest --show-capture=log --log-format="%(asctime)s %(levelname)-8s %(name)s:%(filename)s:%(lineno)s %(message)s" --log-date-format="%H:%M:%S" tests/components/adaptive_lighting/ 36 | ``` 37 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/gl.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "step": { 4 | "init": { 5 | "data_description": { 6 | "sleep_brightness": "Porcentaxe de brillo das luces en modo durmir. 😴", 7 | "send_split_delay": "Retraso (ms) entre `separate_turn_on_commands`", 8 | "sunrise_offset": "Axusta a hora do amencer cun desfasamento positivo ou negativo en segundos. ⏰", 9 | "sunset_offset": "Axusta a hora da posta de sol cun desfasamento positivo ou negativo en segundos. ⏰", 10 | "interval": "Frecuencia para adaptar as luces, en segundos. 🔄" 11 | }, 12 | "title": "Configuración de Iluminación Adaptativa" 13 | } 14 | } 15 | }, 16 | "title": "Iluminación Adaptativa", 17 | "services": { 18 | "change_switch_settings": { 19 | "fields": { 20 | "sleep_brightness": { 21 | "description": "Porcentaxe de brillo das luces en modo durmir. 😴" 22 | }, 23 | "only_once": { 24 | "description": "Adaptar luces só cando estean acesas (`true`) ou mantelas adaptándose (`false`). 🔄" 25 | }, 26 | "sunrise_offset": { 27 | "description": "Axusta a hora do amencer cun desfasamento positivo ou negativo en segundos. ⏰" 28 | }, 29 | "sunset_offset": { 30 | "description": "Axusta a hora da posta de sol cun desfasamento positivo ou negativo en segundos. ⏰" 31 | }, 32 | "transition": { 33 | "description": "Duración da transición cando as luces cambian, en segundos. 🕑" 34 | } 35 | } 36 | } 37 | }, 38 | "config": { 39 | "step": { 40 | "user": { 41 | "title": "Escolle un nome para a instancia de Iluminación Dinámica" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/af.json: -------------------------------------------------------------------------------- 1 | { 2 | "services": { 3 | "apply": { 4 | "description": "Pas die huidige Adaptive Lighting-instellings op ligte toe.", 5 | "fields": { 6 | "lights": { 7 | "description": "'n Lig (of lys van ligte) om die instellings op toe te pas. 💡" 8 | } 9 | } 10 | }, 11 | "change_switch_settings": { 12 | "fields": { 13 | "only_once": { 14 | "description": "Pas ligte net aan wanneer hulle aangeskakel is (`true`) of hou aan om dit aan te pas (`false`)" 15 | }, 16 | "sunrise_offset": { 17 | "description": "Pas sonsopkomstyd aan met 'n positiewe of negatiewe afwyking in sekondes. ⏰" 18 | }, 19 | "sunset_offset": { 20 | "description": "Pas sonsondergangtyd aan met 'n positiewe of negatiewe afwyking in sekondes. ⏰" 21 | } 22 | } 23 | } 24 | }, 25 | "options": { 26 | "step": { 27 | "init": { 28 | "title": "Aanpasbare beligting opsies", 29 | "data": { 30 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: Wanneer ligte aanvanklik aangeskakel word. As dit op \"true\" gestel is, pas AL slegs aan as \"light.turn_on\" opgeroep word sonder om kleur of helderheid te spesifiseer. ❌🌈 Dit verhoed bv. aanpassing wanneer 'n toneel geaktiveer word. As `onwaar`, pas AL aan ongeag die teenwoordigheid van kleur of helderheid in die aanvanklike `diens_data`. Moet `oorname_beheer` geaktiveer moet word. 🕵️ " 31 | }, 32 | "data_description": { 33 | "sunrise_offset": "Pas sonsopkomstyd aan met 'n positiewe of negatiewe afwyking in sekondes. ⏰", 34 | "sunset_offset": "Pas sonsondergangtyd aan met 'n positiewe of negatiewe afwyking in sekondes. ⏰" 35 | } 36 | } 37 | } 38 | }, 39 | "title": "Aanpasbare beligting" 40 | } 41 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for Adaptive Lighting integration.""" 2 | 3 | from homeassistant.components import adaptive_lighting 4 | from homeassistant.components.adaptive_lighting.const import ( 5 | DEFAULT_NAME, 6 | UNDO_UPDATE_LISTENER, 7 | ) 8 | from homeassistant.config_entries import ConfigEntryState 9 | from homeassistant.const import CONF_NAME 10 | from homeassistant.setup import async_setup_component 11 | 12 | from tests.common import MockConfigEntry 13 | 14 | 15 | async def test_setup_with_config(hass): 16 | """Test that we import the config and setup the integration.""" 17 | config = { 18 | adaptive_lighting.DOMAIN: { 19 | adaptive_lighting.CONF_NAME: DEFAULT_NAME, 20 | }, 21 | } 22 | assert await async_setup_component(hass, adaptive_lighting.DOMAIN, config) 23 | assert adaptive_lighting.DOMAIN in hass.data 24 | 25 | 26 | async def test_successful_config_entry(hass): 27 | """Test that Adaptive Lighting is configured successfully.""" 28 | entry = MockConfigEntry( 29 | domain=adaptive_lighting.DOMAIN, 30 | data={CONF_NAME: DEFAULT_NAME}, 31 | ) 32 | entry.add_to_hass(hass) 33 | 34 | assert await hass.config_entries.async_setup(entry.entry_id) 35 | 36 | assert entry.state == ConfigEntryState.LOADED 37 | 38 | assert UNDO_UPDATE_LISTENER in hass.data[adaptive_lighting.DOMAIN][entry.entry_id] 39 | 40 | 41 | async def test_unload_entry(hass): 42 | """Test removing Adaptive Lighting.""" 43 | entry = MockConfigEntry( 44 | domain=adaptive_lighting.DOMAIN, 45 | data={CONF_NAME: DEFAULT_NAME}, 46 | ) 47 | entry.add_to_hass(hass) 48 | 49 | assert await hass.config_entries.async_setup(entry.entry_id) 50 | 51 | assert await hass.config_entries.async_unload(entry.entry_id) 52 | await hass.async_block_till_done() 53 | 54 | assert entry.state == ConfigEntryState.NOT_LOADED 55 | assert adaptive_lighting.DOMAIN not in hass.data 56 | -------------------------------------------------------------------------------- /.github/workflows/update-test-matrix.yaml: -------------------------------------------------------------------------------- 1 | name: Update Test Matrix 2 | 3 | on: 4 | schedule: 5 | # Run weekly on Monday at 9:00 UTC 6 | - cron: "0 9 * * 1" 7 | workflow_dispatch: # Allow manual trigger 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | update-matrix: 15 | name: Update HA Core versions in test matrix 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v6 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v6 23 | with: 24 | python-version: "3.14.2" 25 | 26 | - name: Update test matrix 27 | run: python scripts/update-test-matrix.py 28 | 29 | - name: Check for changes 30 | id: changes 31 | run: | 32 | if git diff --quiet .github/workflows/pytest.yaml; then 33 | echo "changed=false" >> $GITHUB_OUTPUT 34 | else 35 | echo "changed=true" >> $GITHUB_OUTPUT 36 | echo "Detected changes:" 37 | git diff .github/workflows/pytest.yaml 38 | fi 39 | 40 | - name: Create Pull Request 41 | if: steps.changes.outputs.changed == 'true' 42 | uses: peter-evans/create-pull-request@v8 43 | with: 44 | token: ${{ secrets.GITHUB_TOKEN }} 45 | commit-message: "ci: update HA Core test matrix versions" 46 | title: "ci: Update Home Assistant Core test matrix" 47 | body: | 48 | This PR automatically updates the pytest workflow to test against the latest Home Assistant Core versions. 49 | 50 | ## Changes 51 | - Updated HA Core versions in the test matrix to include latest patch releases 52 | 53 | --- 54 | 🤖 Generated automatically by the update-test-matrix workflow 55 | branch: update-test-matrix 56 | delete-branch: true 57 | labels: | 58 | automation 59 | ci 60 | -------------------------------------------------------------------------------- /.github/workflows/update-readme.yml: -------------------------------------------------------------------------------- 1 | name: Update README.md, strings.json, and services.yaml 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "README.md" 9 | - "custom_components/adaptive_lighting/const.py" 10 | - ".github/workflows/update-readme.yml" 11 | pull_request: 12 | 13 | jobs: 14 | update_readme: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code from GitHub 18 | uses: actions/checkout@v6 19 | 20 | - name: Install Home Assistant 21 | uses: ./.github/workflows/install_dependencies 22 | with: 23 | python-version: "3.13" 24 | 25 | - name: Install markdown-code-runner and README code dependencies 26 | run: | 27 | uv pip install markdown-code-runner==2.1.0 pandas tabulate 28 | 29 | - name: Run markdown-code-runner 30 | run: uv run markdown-code-runner --verbose README.md 31 | 32 | - name: Run update services.yaml 33 | run: uv run python .github/update-services.py 34 | 35 | - name: Run update strings.json 36 | run: uv run python .github/update-strings.py 37 | 38 | - name: Commit updated README.md, strings.json, and services.yaml 39 | id: commit 40 | run: | 41 | git add -u . 42 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 43 | git config --local user.name "github-actions[bot]" 44 | if git diff --quiet && git diff --staged --quiet; then 45 | echo "No changes in README.md, strings.json, and services.yaml, skipping commit." 46 | echo "commit_status=skipped" >> $GITHUB_ENV 47 | else 48 | git commit -m "Update README.md, strings.json, and services.yaml" 49 | echo "commit_status=committed" >> $GITHUB_ENV 50 | fi 51 | 52 | - name: Push changes 53 | if: env.commit_status == 'committed' 54 | uses: ad-m/github-push-action@master 55 | with: 56 | github_token: ${{ secrets.GITHUB_TOKEN }} 57 | branch: ${{ github.head_ref }} 58 | -------------------------------------------------------------------------------- /.github/workflows/deploy-webapp.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying WebAssembly app to GitHub Pages 2 | name: Deploy WebAssembly app to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v6 34 | 35 | - name: Set Up Python 36 | uses: actions/setup-python@v6 37 | with: 38 | python-version: 3.14.2 39 | 40 | - name: Install Dependencies 41 | run: | 42 | pip install -r webapp/requirements.txt 43 | pip install shinylive 44 | 45 | - name: Build the WebAssembly app 46 | run: | 47 | set -ex 48 | cp custom_components/adaptive_lighting/color_and_brightness.py webapp/color_and_brightness.py 49 | sed -i 's/homeassistant.util.color/homeassistant_util_color/g' "webapp/color_and_brightness.py" 50 | shinylive export webapp site 51 | 52 | - name: Setup Pages 53 | uses: actions/configure-pages@v5 54 | 55 | - name: Upload artifact 56 | uses: actions/upload-pages-artifact@v4 57 | with: 58 | # Upload the 'site' directory, where your app has been built 59 | path: "site" 60 | 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # See tests/README.md for instructions on how to run the tests. 2 | 3 | # tl;dr: 4 | # Run the following command in the adaptive-lighting repo folder to run the tests: 5 | # docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest 6 | 7 | # Optionally build the image yourself with: 8 | # docker build -t basnijholt/adaptive-lighting:latest . 9 | 10 | FROM ghcr.io/astral-sh/uv:debian 11 | 12 | # Install build dependencies for Python extensions 13 | RUN apt-get update && apt-get install -y --no-install-recommends \ 14 | python3-dev \ 15 | build-essential \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # Clone home-assistant/core 19 | RUN git clone --depth 1 --branch dev https://github.com/home-assistant/core.git /core 20 | 21 | # Copy the Adaptive Lighting repository 22 | COPY . /app/ 23 | 24 | # Setup symlinks in core 25 | RUN ln -s /core /app/core && /app/scripts/setup-symlinks 26 | 27 | # Install home-assistant/core dependencies 28 | RUN mkdir -p /.venv 29 | ENV UV_PROJECT_ENVIRONMENT=/.venv UV_PYTHON=3.13 PATH="/.venv/bin:$PATH" 30 | RUN uv venv 31 | RUN /app/scripts/setup-dependencies 32 | 33 | WORKDIR /app/core 34 | 35 | # Make 'custom_components/adaptive_lighting' imports available to tests 36 | ENV PYTHONPATH="${PYTHONPATH}:/app" 37 | 38 | ENTRYPOINT ["python3", \ 39 | # Enable Python development mode 40 | "-X", "dev", \ 41 | # Run pytest 42 | "-m", "pytest", \ 43 | # Verbose output 44 | "-vvv", \ 45 | # Set a timeout of 9 seconds per test 46 | "--timeout=9", \ 47 | # Print the 10 slowest tests 48 | "--durations=10", \ 49 | # Measure code coverage for the 'homeassistant' package 50 | "--cov='homeassistant'", \ 51 | # Generate an XML report of the code coverage 52 | "--cov-report=xml", \ 53 | # Generate an HTML report of the code coverage 54 | "--cov-report=html", \ 55 | # Print a summary of the code coverage in the console 56 | "--cov-report=term", \ 57 | # Print logs in color 58 | "--color=yes", \ 59 | # Print a count of test results in the console 60 | "-o", "console_output_style=count"] 61 | 62 | # Run tests in the 'tests/components/adaptive_lighting' directory 63 | CMD ["tests/components/adaptive_lighting"] 64 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | pytest: 10 | name: Run pytest 11 | runs-on: ubuntu-24.04 12 | timeout-minutes: 60 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - core-version: "2024.12.5" 18 | python-version: "3.12" 19 | - core-version: "2025.1.4" 20 | python-version: "3.12" 21 | - core-version: "2025.2.5" 22 | python-version: "3.13" 23 | - core-version: "2025.3.4" 24 | python-version: "3.13" 25 | - core-version: "2025.4.4" 26 | python-version: "3.13" 27 | - core-version: "2025.5.3" 28 | python-version: "3.13" 29 | - core-version: "2025.6.3" 30 | python-version: "3.13" 31 | - core-version: "2025.7.4" 32 | python-version: "3.13" 33 | - core-version: "2025.8.3" 34 | python-version: "3.13" 35 | - core-version: "2025.9.4" 36 | python-version: "3.13" 37 | - core-version: "2025.10.4" 38 | python-version: "3.13" 39 | - core-version: "2025.11.3" 40 | python-version: "3.13" 41 | - core-version: "dev" 42 | python-version: "3.13" 43 | steps: 44 | - name: Check out code from GitHub 45 | uses: actions/checkout@v6 46 | 47 | - name: Install Home Assistant 48 | uses: ./.github/workflows/install_dependencies 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | core-version: ${{ matrix.core-version }} 52 | 53 | - name: Run pytest 54 | timeout-minutes: 60 55 | run: | 56 | export PYTHONPATH=${PYTHONPATH}:${PWD} 57 | source .venv/bin/activate 58 | cd core 59 | python3 -X dev -m pytest \ 60 | -vvv \ 61 | -qq \ 62 | --timeout=9 \ 63 | --durations=10 \ 64 | --cov="homeassistant" \ 65 | --cov-report=xml \ 66 | -o console_output_style=count \ 67 | -p no:sugar \ 68 | tests/components/adaptive_lighting 69 | -------------------------------------------------------------------------------- /.github/update-strings.py: -------------------------------------------------------------------------------- 1 | """Update strings.json and en.json from const.py.""" 2 | 3 | import json 4 | import sys 5 | from pathlib import Path 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import yaml 9 | 10 | sys.path.append(str(Path(__file__).parent.parent)) 11 | 12 | from custom_components.adaptive_lighting import const 13 | 14 | folder = Path("custom_components") / "adaptive_lighting" 15 | strings_fname = folder / "strings.json" 16 | en_fname = folder / "translations" / "en.json" 17 | with strings_fname.open() as f: 18 | strings = json.load(f) 19 | 20 | # Set "options" 21 | data = {} 22 | data_description = {} 23 | for k, _, typ in const.VALIDATION_TUPLES: 24 | desc = const.DOCS[k] 25 | if len(desc) > 40 and typ not in (bool, cv.entity_ids): 26 | data[k] = k 27 | data_description[k] = desc 28 | else: 29 | data[k] = f"{k}: {desc}" 30 | strings["options"]["step"]["init"]["data"] = data 31 | strings["options"]["step"]["init"]["data_description"] = data_description 32 | 33 | # Set "services" 34 | services_filename = Path("custom_components") / "adaptive_lighting" / "services.yaml" 35 | with open(services_filename) as f: # noqa: PTH123 36 | services = yaml.safe_load(f) 37 | services_json = {} 38 | for service_name, dct in services.items(): 39 | services_json[service_name] = { 40 | "name": service_name, 41 | "description": dct["description"], 42 | "fields": {}, 43 | } 44 | for field_name, field in dct["fields"].items(): 45 | services_json[service_name]["fields"][field_name] = { 46 | "description": field["description"], 47 | "name": field_name, 48 | } 49 | strings["services"] = services_json 50 | 51 | # Write changes to strings.json 52 | with strings_fname.open("w") as f: 53 | json.dump(strings, f, indent=2, ensure_ascii=False) 54 | f.write("\n") 55 | 56 | # Sync changes from strings.json to en.json 57 | with en_fname.open() as f: 58 | en = json.load(f) 59 | 60 | en["config"]["step"]["user"] = strings["config"]["step"]["user"] 61 | en["options"]["step"]["init"]["data"] = data 62 | en["options"]["step"]["init"]["data_description"] = data_description 63 | en["services"] = services_json 64 | 65 | with en_fname.open("w") as f: 66 | json.dump(en, f, indent=2, ensure_ascii=False) 67 | f.write("\n") 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDEs 132 | .vscode 133 | .idea 134 | 135 | # Home Assistant configuration 136 | config/* 137 | !config/configuration.yaml 138 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug in adaptive-lighting. 4 | title: '' 5 | labels: kind/bug, kind/feature, need/triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Home Assistant Adaptive Lighting Issue Template 11 | 12 | ## Bug Reports 13 | 14 | If you need help with using or configuring Adaptive Lighting, please [open a Q&A discussion thread here](https://github.com/basnijholt/adaptive-lighting/discussions/new?category=q-a) instead. 15 | 16 | ### Before submitting a bug report, please follow these troubleshooting steps: 17 | 18 | Please confirm that you have completed the following steps: 19 | 20 | - [ ] I have updated to the [latest Adaptive Lighting version](https://github.com/basnijholt/adaptive-lighting/releases) available in [HACS](https://hacs.xyz/). 21 | - [ ] I have reviewed the [Troubleshooting Section](https://github.com/basnijholt/adaptive-lighting#sos-troubleshooting) in the [README](https://github.com/basnijholt/adaptive-lighting#readme). 22 | - [ ] (If using Zigbee2MQTT) I have read the [Zigbee2MQTT troubleshooting guide](https://github.com/basnijholt/adaptive-lighting#zigbee2mqtt) in the [README](https://github.com/basnijholt/adaptive-lighting#readme). 23 | - [ ] I have checked the [V2 Roadmap](https://github.com/basnijholt/adaptive-lighting/discussions/291) and [open issues](https://github.com/basnijholt/adaptive-lighting/issues) to ensure my issue isn't a duplicate. 24 | 25 | 26 | ### Required information for bug reports: 27 | 28 | Please include the following information in your issue. 29 | 30 | *Issues missing this information may not be addressed.* 31 | 32 | 1. **Debug logs** captured while the issue occurred. [See here for instructions on enabling debug logging](https://github.com/basnijholt/adaptive-lighting#troubleshooting): 33 | 34 | ``` 35 | 36 | ``` 37 | 38 | 2. [Your Adaptive Lighting configuration](https://github.com/basnijholt/adaptive-lighting#gear-configuration): 39 | 40 | ``` 41 | 42 | ``` 43 | 44 | 3. (If using Zigbee2MQTT), provide your configuration files (**remove all personal information before posting**): 45 | - `devices.yaml` 46 | - `groups.yaml` 47 | - `configuration.yaml` ⚠️; **Warning** _**REMOVE ALL of the PERSONAL INFORMATION BELOW before posting**_ ⚠️; 48 | - mqtt: `server`: 49 | - mqtt: `user`: 50 | - mqtt: `password`: 51 | - advanced: `pan_id`: 52 | - advanced: `network_key`: 53 | - anything in `log_syslog` if you use this 54 | - Brand and model number of problematic light(s) 55 | ``` 56 | 57 | ``` 58 | 59 | 4. Describe the bug and how to reproduce it: 60 | 61 | 62 | 63 | 5. Steps to reproduce the behavior: 64 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/et.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Kohanduv valgus", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Vali kohanduva valguse üksuse nimi", 7 | "description": "Igas üksuses võib olla mitu valgustit!", 8 | "data": { 9 | "name": "Nimi" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Üksus on juba seadistatud" 15 | } 16 | }, 17 | "options": { 18 | "step": { 19 | "init": { 20 | "title": "Kohanduva valguse suvandid", 21 | "description": "Kohanduva valguse suvandid. Valikute nimetused ühtuvad YAML kirjes olevatega. Valikuid ei kuvata kui seadistus on tehtud YAML kirjes.", 22 | "data": { 23 | "lights": "valgustid", 24 | "initial_transition": "Algne üleminek kui valgustid lülituvad sisse/välja või unerežiim muutub", 25 | "interval": "Intervall, aeg muutuste vahel sekundites", 26 | "max_brightness": "Suurim heledus %", 27 | "max_color_temp": "Suurim värvustemperatuur Kelvinites", 28 | "min_brightness": "Vähim heledus %", 29 | "min_color_temp": "Vähim värvustemperatuur Kelvinites", 30 | "only_once": "Ainult üks kord, rakendub ainult valgusti sisselülitamisel", 31 | "prefer_rgb_color": "Eelista RGB värve, võimalusel kasuta RGB sätteid värvustemperatuuri asemel", 32 | "separate_turn_on_commands": "Eraldi lülitused iga valiku (värvus, heledus jne.) sisselülitamiseks, mõned valgustid vajavad seda.", 33 | "sleep_brightness": "Unerežiimi heledus %", 34 | "sleep_color_temp": "Uneržiimi värvus Kelvinites", 35 | "sunrise_offset": "Nihe päikesetõusust, +/- sekundit", 36 | "sunrise_time": "Päikesetõusu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)", 37 | "sunset_offset": "Nihe päikeseloojangust, +/- sekundit", 38 | "sunset_time": "Päikeseloojangu aeg 'HH:MM:SS' vormingus. (Kui jätta tühjaks kasutatakse asukohajärgset)", 39 | "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.", 40 | "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'!)", 41 | "transition": "Üleminekud, sekundites" 42 | } 43 | } 44 | }, 45 | "error": { 46 | "option_error": "Vigane suvand", 47 | "entity_missing": "Valitud valgust ei leitud" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/test_hass_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for Adaptive Lighting HASS utils.""" 2 | 3 | from unittest.mock import AsyncMock 4 | 5 | from homeassistant.components.adaptive_lighting.adaptation_utils import ServiceData 6 | from homeassistant.components.adaptive_lighting.hass_utils import ( 7 | setup_service_call_interceptor, 8 | ) 9 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 10 | from homeassistant.const import SERVICE_TURN_ON 11 | from homeassistant.core import ServiceCall 12 | from homeassistant.util.read_only_dict import ReadOnlyDict 13 | 14 | 15 | async def test_setup_service_call_interceptor(hass): 16 | """Test setup and removal of service call interceptor.""" 17 | service_func_mock = AsyncMock() 18 | hass.services.async_register(LIGHT_DOMAIN, SERVICE_TURN_ON, service_func_mock) 19 | 20 | async def service_call(): 21 | await hass.services.async_call( 22 | LIGHT_DOMAIN, 23 | SERVICE_TURN_ON, 24 | {}, 25 | blocking=True, 26 | ) 27 | 28 | # Test if service is called 29 | 30 | await service_call() 31 | assert service_func_mock.call_count == 1 32 | 33 | # Test if interceptor is called after setup 34 | 35 | intercept_func_mock = AsyncMock() 36 | remove_interceptor = setup_service_call_interceptor( 37 | hass, 38 | LIGHT_DOMAIN, 39 | SERVICE_TURN_ON, 40 | intercept_func_mock, 41 | ) 42 | 43 | await service_call() 44 | assert service_func_mock.call_count == 2 45 | assert intercept_func_mock.call_count == 1 46 | 47 | # Test if interceptor is no longer called after removal 48 | 49 | remove_interceptor() 50 | await service_call() 51 | assert service_func_mock.call_count == 3 52 | assert intercept_func_mock.call_count == 1 53 | 54 | 55 | async def test_service_call_interceptor_data_manipulation(hass): 56 | """Test service call data manipulation by service call interceptor.""" 57 | service_func_mock = AsyncMock() 58 | hass.services.async_register(LIGHT_DOMAIN, SERVICE_TURN_ON, service_func_mock) 59 | 60 | async def intercept_func(call: ServiceCall, data: ServiceData): 61 | data["test1"] = "changed" 62 | data["test2"] = "added" 63 | 64 | setup_service_call_interceptor( 65 | hass, 66 | LIGHT_DOMAIN, 67 | SERVICE_TURN_ON, 68 | intercept_func, 69 | ) 70 | 71 | await hass.services.async_call( 72 | LIGHT_DOMAIN, 73 | SERVICE_TURN_ON, 74 | {"test1": "initial"}, 75 | blocking=True, 76 | ) 77 | 78 | (service_call,) = service_func_mock.call_args[0] 79 | assert service_call.data == {"test1": "changed", "test2": "added"} 80 | assert isinstance(service_call.data, ReadOnlyDict) 81 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | [lint] 5 | select = ["ALL"] 6 | 7 | # All the ones without a comment were the ones that are currently violated 8 | # by the codebase. The plan is to fix them all (when sensible) and then enable them. 9 | ignore = [ 10 | "ANN", 11 | "ANN101", # Missing type annotation for {name} in method 12 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} 13 | "D401", # First line of docstring should be in imperative mood 14 | "E501", # line too long 15 | "FBT001", # Boolean positional arg in function definition 16 | "FBT002", # Boolean default value in function definition 17 | "FIX004", # Line contains HACK, consider resolving the issue 18 | "PD901", # df is a bad variable name. Be kinder to your future self. 19 | "PERF203", # `try`-`except` within a loop incurs performance overhead 20 | "PLR0913", # Too many arguments to function call (N > 5) 21 | "PLR2004", # Magic value used in comparison, consider replacing X with a constant variable 22 | "S101", # Use of assert detected 23 | "SLF001", # Private member accessed 24 | ] 25 | 26 | [lint.per-file-ignores] 27 | "tests/*.py" = [ 28 | "ARG001", # Unused function argument: `call` 29 | "D100", # Missing docstring in public module 30 | "D103", # Missing docstring in public function 31 | "D205", # 1 blank line required between summary line and description 32 | "D400", # First line should end with a period 33 | "D415", # First line should end with a period, question mark, or 34 | "DTZ001", # The use of `datetime.datetime()` without `tzinfo` 35 | "ERA001", # Found commented-out code 36 | "FBT003", # Boolean positional value in function call 37 | "FIX002", # Line contains TODO, consider resolving the issue 38 | "G004", # Logging statement uses f-string 39 | "PLR0915", # Too many statements (94 > 50) 40 | "PT004", # Fixture `cleanup` does not return anything, add leading underscore 41 | "PT007", # Wrong values type in `@pytest.mark.parametrize` expected `list` of 42 | "S311", # Standard pseudo-random generators are not suitable for cryptographic 43 | "TD002", # Missing author in TODO; try: `# TODO(): ...` or `# TODO 44 | "TD003", # Missing issue link on the line following this TODO 45 | ] 46 | ".github/*py" = ["INP001"] 47 | "webapp/homeassistant_util_color.py" = ["ALL"] 48 | "webapp/app.py" = ["INP001", "DTZ011", "A002"] 49 | "custom_components/adaptive_lighting/homeassistant_util_color.py" = ["ALL"] 50 | 51 | [lint.flake8-pytest-style] 52 | fixture-parentheses = false 53 | 54 | [lint.pyupgrade] 55 | keep-runtime-typing = true 56 | 57 | [lint.mccabe] 58 | max-complexity = 25 59 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | # Configuration for welcome - https://github.com/behaviorbot/welcome 2 | 3 | # Configuration for new-issue-welcome - https://github.com/behaviorbot/new-issue-welcome 4 | # Comment to be posted to on first time issues 5 | newIssueWelcomeComment: > 6 | Thank you for submitting your first issue to this repository! A maintainer 7 | will be here shortly to triage and review. 8 | 9 | In the meantime, please double-check that you have provided all the 10 | necessary information to make this process easy! Any information that can 11 | help save additional round trips is useful! We currently aim to give 12 | initial feedback within **two business days**. If this does not happen, feel 13 | free to leave a comment. 14 | 15 | Please keep an eye on how this issue will be labeled, as labels give an 16 | overview of priorities, assignments and additional actions requested by the 17 | maintainers: 18 | 19 | - "Priority" labels will show how urgent this is for the team. 20 | - "Status" labels will show if this is ready to be worked on, blocked, or in progress. 21 | - "Need" labels will indicate if additional input or analysis is required. 22 | 23 | Finally, remember to use [the discussion tab](https://github.com/basnijholt/adaptive-lighting/discussions) if you just need general 24 | support. 25 | 26 | # Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome 27 | # Comment to be posted to on PRs from first time contributors in your repository 28 | newPRWelcomeComment: > 29 | Thank you for submitting this PR! 30 | 31 | A maintainer will be here shortly to review it. 32 | 33 | We are super grateful! Help us by making sure that: 34 | 35 | * The context for this PR is clear, with relevant discussion, decisions 36 | and stakeholders linked/mentioned. 37 | 38 | * Your contribution itself is clear (code comments, self-review for the 39 | rest) and in its best form. 40 | 41 | Getting other community members to do a review would be great help too on 42 | complex PRs. If you are unsure about something, just leave us a comment. 43 | 44 | Next steps: 45 | 46 | * A maintainer will triage and assign priority to this PR, commenting on 47 | any missing things and potentially assigning a reviewer for high 48 | priority items. 49 | 50 | * The PR gets reviews, discussed and approvals as needed. 51 | 52 | * The PR is merged by maintainers when it has been approved and comments addressed. 53 | 54 | We currently aim to provide initial feedback/triaging within **two business 55 | days**. Please keep an eye on any labelling actions, as these will indicate 56 | priorities and status of your contribution. 57 | 58 | We are very grateful for your contribution! 59 | 60 | 61 | # Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge 62 | # Comment to be posted to on pull requests merged by a first time user 63 | # Currently disabled 64 | #firstPRMergeComment: "" 65 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for the Adaptive Lighting custom components.""" 2 | 3 | from __future__ import annotations 4 | 5 | import base64 6 | import math 7 | from typing import TYPE_CHECKING, Any 8 | 9 | if TYPE_CHECKING: 10 | from homeassistant.core import HomeAssistant 11 | 12 | 13 | def clamp(value: float, minimum: float, maximum: float) -> float: 14 | """Clamp value between minimum and maximum.""" 15 | return max(minimum, min(value, maximum)) 16 | 17 | 18 | def int_to_base36(num: int) -> str: 19 | """Convert an integer to its base-36 representation using numbers and uppercase letters. 20 | 21 | Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive 22 | alphanumeric representation. The function takes an integer `num` as input and returns 23 | its base-36 representation as a string. 24 | 25 | Parameters 26 | ---------- 27 | num 28 | The integer to convert to base-36. 29 | 30 | Returns 31 | ------- 32 | str 33 | The base-36 representation of the input integer. 34 | 35 | Examples 36 | -------- 37 | >>> num = 123456 38 | >>> base36_num = int_to_base36(num) 39 | >>> print(base36_num) 40 | '2N9' 41 | 42 | """ 43 | alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" 44 | 45 | if num == 0: 46 | return alphanumeric_chars[0] 47 | 48 | base36_str = "" 49 | base = len(alphanumeric_chars) 50 | 51 | while num: 52 | num, remainder = divmod(num, base) 53 | base36_str = alphanumeric_chars[remainder] + base36_str 54 | 55 | return base36_str 56 | 57 | 58 | def short_hash(string: str, length: int = 4) -> str: 59 | """Create a hash of 'string' with length 'length'.""" 60 | return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length] 61 | 62 | 63 | def remove_vowels(input_str: str, length: int = 4) -> str: 64 | """Remove vowels from a string and return a string of length 'length'.""" 65 | vowels = "aeiouAEIOU" 66 | output_str = "".join([char for char in input_str if char not in vowels]) 67 | return output_str.zfill(length)[:length] 68 | 69 | 70 | def color_difference_redmean( 71 | rgb1: tuple[float, float, float], 72 | rgb2: tuple[float, float, float], 73 | ) -> float: 74 | """Distance between colors in RGB space (redmean metric). 75 | 76 | The maximal distance between (255, 255, 255) and (0, 0, 0) ≈ 765. 77 | 78 | Sources: 79 | - https://en.wikipedia.org/wiki/Color_difference#Euclidean 80 | - https://www.compuphase.com/cmetric.htm 81 | """ 82 | r_hat = (rgb1[0] + rgb2[0]) / 2 83 | delta_r, delta_g, delta_b = ( 84 | (col1 - col2) for col1, col2 in zip(rgb1, rgb2, strict=True) 85 | ) 86 | red_term = (2 + r_hat / 256) * delta_r**2 87 | green_term = 4 * delta_g**2 88 | blue_term = (2 + (255 - r_hat) / 256) * delta_b**2 89 | return math.sqrt(red_term + green_term + blue_term) 90 | 91 | 92 | def get_friendly_name(hass: HomeAssistant, entity_id: str) -> str: 93 | """Retrieve the friendly name of an entity.""" 94 | state = hass.states.get(entity_id) 95 | if state and hasattr(state, "attributes"): 96 | attributes: dict[str, Any] = dict(getattr(state, "attributes", {})) 97 | return attributes.get("friendly_name", entity_id) 98 | return entity_id 99 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/__init__.py: -------------------------------------------------------------------------------- 1 | """Adaptive Lighting integration in Home-Assistant.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | import homeassistant.helpers.config_validation as cv 7 | import voluptuous as vol 8 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 9 | from homeassistant.const import CONF_SOURCE 10 | from homeassistant.core import Event, HomeAssistant 11 | 12 | from .const import ( 13 | _DOMAIN_SCHEMA, # pyright: ignore[reportPrivateUsage] 14 | ATTR_ADAPTIVE_LIGHTING_MANAGER, 15 | CONF_NAME, 16 | DOMAIN, 17 | UNDO_UPDATE_LISTENER, 18 | ) 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | PLATFORMS = ["switch"] 23 | 24 | 25 | def _all_unique_names(value: list[dict[str, Any]]) -> list[dict[str, Any]]: 26 | """Validate that all entities have a unique profile name.""" 27 | hosts = [device[CONF_NAME] for device in value] 28 | schema = vol.Schema(vol.Unique()) 29 | schema(hosts) 30 | return value 31 | 32 | 33 | CONFIG_SCHEMA = vol.Schema( 34 | {DOMAIN: vol.All(cv.ensure_list, [_DOMAIN_SCHEMA], _all_unique_names)}, 35 | extra=vol.ALLOW_EXTRA, 36 | ) 37 | 38 | 39 | async def reload_configuration_yaml(event: Event) -> None: 40 | """Reload configuration.yaml.""" 41 | hass: HomeAssistant | None = event.data.get("hass") 42 | if hass is not None: 43 | await hass.services.async_call("homeassistant", "check_config", {}) 44 | else: 45 | _LOGGER.error("HomeAssistant instance not found in event data.") 46 | 47 | 48 | async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool: 49 | """Import integration from config.""" 50 | if DOMAIN in config: 51 | for entry in config[DOMAIN]: 52 | hass.async_create_task( 53 | hass.config_entries.flow.async_init( 54 | DOMAIN, 55 | context={CONF_SOURCE: SOURCE_IMPORT}, 56 | data=entry, 57 | ), 58 | ) 59 | return True 60 | 61 | 62 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 63 | """Set up the component.""" 64 | data = hass.data.setdefault(DOMAIN, {}) 65 | 66 | # This will reload any changes the user made to any YAML configurations. 67 | # Called during 'quick reload' or hass.reload_config_entry 68 | hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml) 69 | 70 | undo_listener = config_entry.add_update_listener(async_update_options) 71 | data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} 72 | await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) 73 | 74 | return True 75 | 76 | 77 | async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: 78 | """Update options.""" 79 | await hass.config_entries.async_reload(config_entry.entry_id) 80 | 81 | 82 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 83 | """Unload a config entry.""" 84 | unload_ok = await hass.config_entries.async_forward_entry_unload( 85 | config_entry, 86 | "switch", 87 | ) 88 | data = hass.data[DOMAIN] 89 | data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() 90 | if unload_ok: 91 | data.pop(config_entry.entry_id) 92 | 93 | if len(data) == 1 and ATTR_ADAPTIVE_LIGHTING_MANAGER in data: 94 | # no more config_entries 95 | manager = data.pop(ATTR_ADAPTIVE_LIGHTING_MANAGER) 96 | manager.disable() 97 | 98 | if not data: 99 | hass.data.pop(DOMAIN) 100 | 101 | return unload_ok 102 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/ro.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "description": "Fiecare instanţă poate conţine mai multe lumini!", 6 | "title": "Alege un nume pentru instanța de Iluminare Adaptivă" 7 | } 8 | }, 9 | "abort": { 10 | "already_configured": "Acest dispozitiv este deja configurat" 11 | } 12 | }, 13 | "options": { 14 | "step": { 15 | "init": { 16 | "data_description": { 17 | "brightness_mode_time_light": "(Se ignoră dacă `modul_de_luminozitate='implicit'`) Durată în secunde a modificării luminozităţii în sus/jos cand poziţia sorelui este înainte sau după răsărit/apus.", 18 | "sunrise_offset": "Ajustați ora răsăritului cu un decalaj pozitiv sau negativ în secunde.⏰", 19 | "autoreset_control_seconds": "Resetare automată al controlului manual după un număr de secunde. Setaţi la 0 pentru a dezactiva.", 20 | "brightness_mode": "Mod de luminozitate de utilizat. Valorile posibile sunt: 'implicit', 'liniar' şi 'hiperbolic' ( ultilizează 'mod_luminozitate_timp_de_noapte' şi 'mod_luminozitate_timp_de_zi').", 21 | "sleep_brightness": "Procentul luminozităţii luminilor în modul 'somn'.", 22 | "interval": "Frecvenţa adaptării luminilor, în secunde.", 23 | "sunset_offset": "Ajustați ora răsăritului cu un decalaj pozitiv sau negativ în secunde." 24 | }, 25 | "title": "Opţiuni Iluminare Adaptivă", 26 | "data": { 27 | "adapt_only_on_bare_turn_on": "adaptează_doar_la_comanda_de_arpindere: La aprinderea iniţială a luminilor. Dacă este activat, IA va adapta luminile doar dacă se invocă 'light.turn_on' fără a specifica culoarea sau luminozitatea. Aceasta, de exemplu, previne adaptarea atunci când se activează o scenă. Dacă este dezactivat,IA va adaptata luminile indiferent de prezența valorilor culorii sau luminozității în service_data. Necesită activarea opţiunii 'preia_controlul. " 28 | } 29 | } 30 | } 31 | }, 32 | "title": "Iluminare Adaptivă", 33 | "services": { 34 | "apply": { 35 | "description": "Aplicaţi luminilor setările curente ale Iluminiării Adaptive luminilor.", 36 | "fields": { 37 | "lights": { 38 | "description": "O lumină (sau listă de lumini) pentru care să se aplice setările." 39 | } 40 | } 41 | }, 42 | "change_switch_settings": { 43 | "fields": { 44 | "entity_id": { 45 | "description": "Numele entităţii." 46 | }, 47 | "sleep_brightness": { 48 | "description": "Procentul luminozităţii luminilor în modul 'somn'." 49 | }, 50 | "sleep_transition": { 51 | "description": "Durata de tranziție (în secunde) atunci când modul de „somn” este activat." 52 | }, 53 | "autoreset_control_seconds": { 54 | "description": "Resetare automată al controlului manual după un număr de secunde. Setaţi la 0 pentru a dezactiva." 55 | }, 56 | "only_once": { 57 | "description": "Adaptează luminile doar la pornire ('activat') sau adaptează continuu ('dezactivat')." 58 | }, 59 | "sunrise_offset": { 60 | "description": "Ajustați ora răsăritului cu un decalaj pozitiv sau negativ în secunde.⏰" 61 | }, 62 | "sunset_offset": { 63 | "description": "Ajustați ora apusului cu un decalaj pozitiv sau negativ în secunde." 64 | } 65 | }, 66 | "description": "Schimbați orice setări dorită în comutator. Toate opțiunile de aici sunt la fel ca în fluxul de configurare." 67 | }, 68 | "set_manual_control": { 69 | "fields": { 70 | "lights": { 71 | "description": "Numele luminii (luminilor) , dacă nu sunt specificate, sunt selectate toate luminile ce aparţin de comutator." 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/_docs_helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import homeassistant.helpers.config_validation as cv 4 | import pandas as pd 5 | import voluptuous as vol 6 | from homeassistant.helpers import selector 7 | 8 | from .const import ( 9 | DOCS, 10 | DOCS_APPLY, 11 | DOCS_MANUAL_CONTROL, 12 | SET_MANUAL_CONTROL_SCHEMA, 13 | VALIDATION_TUPLES, 14 | apply_service_schema, 15 | ) 16 | 17 | 18 | def _format_voluptuous_instance(instance: vol.All) -> str: 19 | coerce_type = None 20 | min_val = None 21 | max_val = None 22 | 23 | for validator in instance.validators: 24 | if isinstance(validator, vol.Coerce): 25 | coerce_type = validator.type.__name__ 26 | elif isinstance(validator, vol.Clamp | vol.Range): 27 | min_val = validator.min 28 | max_val = validator.max 29 | 30 | if min_val is not None and max_val is not None: 31 | return f"`{coerce_type}` {min_val}-{max_val}" 32 | if min_val is not None: 33 | return f"`{coerce_type} > {min_val}`" 34 | if max_val is not None: 35 | return f"`{coerce_type} < {max_val}`" 36 | return f"`{coerce_type}`" 37 | 38 | 39 | def _type_to_str(type_: Any) -> str: # noqa: PLR0911 40 | """Convert a (voluptuous) type to a string.""" 41 | if type_ == cv.entity_ids: 42 | return "list of `entity_id`s" 43 | if type_ in (bool, int, float, str): 44 | return f"`{type_.__name__}`" 45 | if type_ == cv.boolean: 46 | return "bool" 47 | if isinstance(type_, vol.All): 48 | return _format_voluptuous_instance(type_) 49 | if isinstance(type_, vol.In): 50 | return f"one of `{type_.container}`" 51 | if isinstance(type_, selector.SelectSelector): 52 | return f"one of `{type_.config['options']}`" 53 | if isinstance(type_, selector.ColorRGBSelector): 54 | return "RGB color" 55 | msg = f"Unknown type: {type_}" 56 | raise ValueError(msg) 57 | 58 | 59 | def generate_config_markdown_table() -> str: 60 | rows: list[dict[str, str]] = [] 61 | for k, default, type_ in VALIDATION_TUPLES: 62 | description = DOCS[k] 63 | row = { 64 | "Variable name": f"`{k}`", 65 | "Description": description, 66 | "Default": f"`{default}`", 67 | "Type": _type_to_str(type_), 68 | } 69 | rows.append(row) 70 | 71 | df = pd.DataFrame(rows) 72 | return df.to_markdown(index=False) 73 | 74 | 75 | def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: 76 | result: dict[str, tuple[Any, Any]] = {} 77 | for key, value in schema.schema.items(): 78 | if isinstance(key, vol.Optional): 79 | default_value = key.default 80 | result[key.schema] = (default_value, value) 81 | return result 82 | 83 | 84 | def _generate_service_markdown_table( 85 | schema: dict[str, tuple[Any, Any]] | vol.Schema, 86 | alternative_docs: dict[str, str] | None = None, 87 | ) -> str: 88 | schema_dict = _schema_to_dict(schema) if isinstance(schema, vol.Schema) else schema 89 | rows: list[dict[str, str]] = [] 90 | for k, (default, type_) in schema_dict.items(): 91 | if alternative_docs is not None and k in alternative_docs: 92 | description = alternative_docs[k] 93 | else: 94 | description = DOCS[k] 95 | row = { 96 | "Service data attribute": f"`{k}`", 97 | "Description": description, 98 | "Required": "✅" if default == vol.UNDEFINED else "❌", 99 | "Type": _type_to_str(type_), 100 | } 101 | rows.append(row) 102 | 103 | df = pd.DataFrame(rows) 104 | return df.to_markdown(index=False) 105 | 106 | 107 | def generate_apply_markdown_table() -> str: 108 | return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY) 109 | 110 | 111 | def generate_set_manual_control_markdown_table() -> str: 112 | return _generate_service_markdown_table( 113 | SET_MANUAL_CONTROL_SCHEMA, 114 | DOCS_MANUAL_CONTROL, 115 | ) 116 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/hass_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for HA core.""" 2 | 3 | import logging 4 | from collections.abc import Awaitable, Callable 5 | 6 | from homeassistant.core import HomeAssistant, ServiceCall 7 | from homeassistant.helpers import device_registry, entity_registry 8 | from homeassistant.util.read_only_dict import ReadOnlyDict 9 | 10 | from .adaptation_utils import ServiceData 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def area_entities(hass: HomeAssistant, area_id: str): 16 | """Get all entities linked to an area.""" 17 | ent_reg = entity_registry.async_get(hass) 18 | entity_ids = [ 19 | entry.entity_id 20 | for entry in entity_registry.async_entries_for_area(ent_reg, area_id) 21 | ] 22 | dev_reg = device_registry.async_get(hass) 23 | entity_ids.extend( 24 | [ 25 | entity.entity_id 26 | for device in device_registry.async_entries_for_area(dev_reg, area_id) 27 | for entity in entity_registry.async_entries_for_device(ent_reg, device.id) 28 | if entity.area_id is None 29 | ], 30 | ) 31 | return entity_ids 32 | 33 | 34 | def setup_service_call_interceptor( 35 | hass: HomeAssistant, 36 | domain: str, 37 | service: str, 38 | intercept_func: Callable[[ServiceCall, ServiceData], Awaitable[None] | None], 39 | ) -> Callable[[], None]: 40 | """Inject a function into a registered service call to preprocess service data. 41 | 42 | The injected interceptor function receives the service call and a writeable data dictionary 43 | (the data of the service call is read-only) before the service call is executed. 44 | """ 45 | try: 46 | # HACK: Access protected attribute of HA service registry. 47 | # This is necessary to replace a registered service handler with our 48 | # proxy handler to intercept calls. 49 | registered_services = ( 50 | hass.services._services # pylint: disable=protected-access # type: ignore[attr-defined] 51 | ) 52 | except AttributeError as error: 53 | msg = ( 54 | "Intercept failed because registered services are no longer" 55 | " accessible (internal API may have changed)" 56 | ) 57 | raise RuntimeError(msg) from error 58 | 59 | if domain not in registered_services or service not in registered_services[domain]: 60 | msg = f"Intercept failed because service {domain}.{service} is not registered" 61 | raise RuntimeError(msg) 62 | 63 | existing_service = registered_services[domain][service] 64 | 65 | async def service_func_proxy(call: ServiceCall) -> None: 66 | try: 67 | # Convert read-only data to writeable dictionary for modification by interceptor 68 | data = dict(call.data) 69 | 70 | # Call interceptor 71 | result = intercept_func(call, data) 72 | if result is not None: 73 | await result 74 | 75 | # Convert data back to read-only 76 | call.data = ReadOnlyDict(data) 77 | except Exception: 78 | # Blindly catch all exceptions to avoid breaking light.turn_on 79 | _LOGGER.exception( 80 | "Error for call '%s' in service_func_proxy", 81 | call.data, 82 | ) 83 | # Call original service handler with processed data 84 | import asyncio 85 | 86 | target = existing_service.job.target 87 | if asyncio.iscoroutinefunction(target): 88 | await target(call) 89 | else: 90 | target(call) 91 | 92 | hass.services.async_register( 93 | domain, 94 | service, 95 | service_func_proxy, 96 | existing_service.schema, 97 | ) 98 | 99 | def remove() -> None: 100 | # Remove the interceptor by reinstalling the original service handler 101 | hass.services.async_register( 102 | domain, 103 | service, 104 | existing_service.job.target, 105 | existing_service.schema, 106 | ) 107 | 108 | return remove 109 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Iluminação Adaptativa", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Escolha um nome para a instância da Iluminação Adaptativa", 7 | "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!", 8 | "data": { 9 | "name": "Nome" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Este dispositivo já está configurado" 15 | } 16 | }, 17 | "options": { 18 | "step": { 19 | "init": { 20 | "title": "Opções da iluminação adaptiva", 21 | "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.", 22 | "data": { 23 | "lights": "luzes", 24 | "initial_transition": "initial_transition: Quando as luzes mudam de 'off' para 'on'. (segundos)", 25 | "sleep_transition": "sleep_transition: Quando 'sleep_state' muda. (segundos)", 26 | "interval": "interval: Tempo entre as atualizações do switch. (segundos)", 27 | "max_brightness": "max_brightness: Maior brilho das luzes durante um ciclo. (%)", 28 | "max_color_temp": "max_color_temp: Matiz mais frio do ciclo de temperatura de cor. (Kelvin)", 29 | "min_brightness": "min_brightness: Menor brilho das luzes durante um ciclo. (%)", 30 | "min_color_temp": "min_color_temp, matiz mais quente do ciclo de temperatura de cor. (Kelvin)", 31 | "only_once": "only_once: Apenas adapte as luzes ao ligá-las.", 32 | "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' em vez de 'color_temp' quando possível.", 33 | "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).", 34 | "sleep_brightness": "sleep_brightness, configuração de brilho para o modo de suspensão. (%)", 35 | "sleep_color_temp": "sleep_color_temp: configuração de temperatura de cor para o modo de suspensão. (Kelvin)", 36 | "sunrise_offset": "sunrise_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto do nascer do sol do ciclo (+/- segundos)", 37 | "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)", 38 | "sunset_offset": "Sunset_offset: Quanto tempo antes (-) ou depois (+) para definir o ponto de pôr do sol do ciclo (+/- segundos)", 39 | "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)", 40 | "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.", 41 | "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'!)", 42 | "transition": "Tempo de transição ao aplicar uma mudança nas luzes (segundos)", 43 | "skip_redundant_commands": "skip_redundant_commands: Deixar de enviar comandos de adaptação cujo estado alvo já seja igual ao estado atual da luz. Minimiza o tráfego de rede e melhora a responsividade da adaptação em algumas situações. 📉Desative se os estados físicos das luzes podem ficar diferentes do estado registrado no HA.", 44 | "transition_until_sleep": "transition_until_sleep: Quando ativada, a Iluminação Adaptativa considerará as configurações de sono como o valor mínimo, transicionando para esses valores após o pôr do sol. 🌙", 45 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: Ao ligar as luzes inicialmente. Se definido como `true`, a Iluminação Adaptativa se adapta somente se o comando `light.turn_on` for chamado sem especificar cor ou brilho. ❌🌈 Isso, por exemplo, impede a adaptação ao ativar uma cena. Se false, a Iluminação Adaptativa se adapta independentemente da presença de cor ou brilho nos dados iniciais do `service_data`. Precisa de `take_over_control` ativado. 🕵️", 46 | "intercept": "intercept: Interceptar e adaptar os chamados `light.turn_on` para ativar a adaptação instantânea de cor e brilho. 🏎️ Desative para luzes que não suportam `light.turn_on` com cor e brilho." 47 | } 48 | } 49 | }, 50 | "error": { 51 | "option_error": "Opção inválida", 52 | "entity_missing": "Uma luz selecionada não foi encontrada" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Iluminação Adaptativa", 3 | "services": { 4 | "change_switch_settings": { 5 | "fields": { 6 | "sunrise_offset": { 7 | "description": "Ajustar a hora do nascer do sol com um offset positivo ou negativo em segundos. ⏰" 8 | }, 9 | "only_once": { 10 | "description": "Adaptar as luzes apenas quando estas estão ligadas (`true`) ou continuar a adaptá-las (`false`)." 11 | }, 12 | "sunset_offset": { 13 | "description": "Ajustar a hora do pôr do sol com um offset positivo ou negativo em segundos. ⏰" 14 | }, 15 | "turn_on_lights": { 16 | "description": "Para ligar luzes que estão neste momento desligadas. 🔆" 17 | }, 18 | "entity_id": { 19 | "description": "ID Entidade do interruptor. 📝" 20 | }, 21 | "sleep_transition": { 22 | "description": "Duração da transição quando o \"modo dormir\" é alternado em segundos. 😴" 23 | }, 24 | "autoreset_control_seconds": { 25 | "description": "Reiniciar o controlo manual automaticamente após um número de segundos. Definir 0 para desativar. ⏲️" 26 | }, 27 | "transition": { 28 | "description": "Duração da transição quando as luzes mudam, em segundos. 🕑" 29 | }, 30 | "max_color_temp": { 31 | "description": "Cor mais fria em Kelvin. ❄️" 32 | }, 33 | "sleep_brightness": { 34 | "description": "Porcentagem do brilho da lâmpadas no modo \"sleep mode\"." 35 | } 36 | }, 37 | "description": "Muda alguma configuração que você quiser no interruptor. Todas as opções aqui são as mesmas que estão no processo de configuração." 38 | }, 39 | "apply": { 40 | "description": "Aplica as definições atuais da Iluminação Adaptativa às luzes.", 41 | "fields": { 42 | "lights": { 43 | "description": "Uma luz (ou lista de luzes) para a qual serão aplicadas as definições.💡" 44 | }, 45 | "transition": { 46 | "description": "Duração da transição quando as luzes mudam, em segundos. 🕑" 47 | } 48 | } 49 | } 50 | }, 51 | "config": { 52 | "abort": { 53 | "already_configured": "Este dispositivo já está configurado" 54 | }, 55 | "step": { 56 | "user": { 57 | "description": "Cada instância pode conter múltiplas luzes!", 58 | "title": "Escolha um nome para a instância de Iluminação Adaptativa" 59 | } 60 | } 61 | }, 62 | "options": { 63 | "step": { 64 | "init": { 65 | "data_description": { 66 | "sunrise_offset": "Ajustar a hora do nascer do sol com um offset positivo ou negativo em segundos. ⏰", 67 | "sunset_offset": "Ajustar a hora do pôr do sol com um offset positivo ou negativo em segundos. ⏰", 68 | "sleep_transition": "Duração da transição quando o \"modo dormir\" é alternado em segundos. 😴", 69 | "autoreset_control_seconds": "Reiniciar o controlo manual automaticamente após um número de segundos. Definir 0 para desativar. ⏲️", 70 | "transition": "Duração da transição quando as luzes mudam, em segundos. 🕑", 71 | "sleep_brightness": "Porcentagem do brilho da lâmpadas no modo \"sleep mode\".", 72 | "brightness_mode": "Brilho que irá ser usado. Possíveis valores são `default`, `linear` e `tanh`(usa `brightness_mode_time_dark` e `brightness_mode_time_light`). 📈" 73 | }, 74 | "title": "Opções da Iluminação Adaptativa", 75 | "description": "Configure um componente da Iluminação Adaptativa. O nome das opções são as mesmas que as do YML. Se você já definiu essa configuração no YAML, nenhuma opção vai aparecer aqui. Para acessar um gráfico que demonstra o efeito dos parâmetros, acesse [esse app](https://basnijholt.github.io/adaptive-lighting). Para mais detalhes, veja a [documentação oficial](https://github.com/basnijholt/adaptive-lighting#readme).", 76 | "data": { 77 | "lights": "lights: Lista das entity_ids das luzes para serem controladas (pode ser vazia). 🌟", 78 | "min_brightness": "min_brightness: Percentagem minima de brilho. 💡", 79 | "max_brightness": "max_brightness: Percentagem máxima de brilho. 💡", 80 | "min_color_temp": "min_color_temp: Cor mais quente em Kelvin. 🔥", 81 | "max_color_temp": "max_color_temp: Cor mais fria em Kelvin. ❄️", 82 | "prefer_rgb_color": "prefer_rgb_color: Quando possível escolher ajuste em RGB em vez de temperatura da cor. 🌈", 83 | "transition_until_sleep": "transition_until_sleep: Quando ativado, Adaptive Lighting usará as definições do modo noturno como os mínimos, passando para esses valores no por do sol. 🌙", 84 | "take_over_control": "take_over_control: Desativa Adaptive Lighting se alguma fonte chamar`light.turn_on` enquanto as luzes estiverem ligadas e a serem controladas. Tomar nota que esta opção chama o serviço `homeassistant.update_entity` a cada `interval`! 🔒" 85 | } 86 | } 87 | }, 88 | "error": { 89 | "option_error": "Opção inválida" 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /scripts/update-test-matrix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Update the pytest workflow matrix with latest HA Core versions. 3 | 4 | This script fetches the latest Home Assistant Core release versions from GitHub 5 | and updates the pytest workflow matrix to test against them. 6 | 7 | Usage: 8 | python scripts/update-test-matrix.py 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | import json 14 | import re 15 | import urllib.request 16 | from pathlib import Path 17 | 18 | # Minimum HA Core version to include in the test matrix 19 | # This should be the oldest version we want to support 20 | MIN_VERSION = (2024, 12) 21 | 22 | 23 | def get_ha_core_versions() -> list[str]: 24 | """Fetch latest stable HA Core versions from GitHub API.""" 25 | all_tags = [] 26 | page = 1 27 | 28 | # Paginate through all tags to ensure we get older versions too 29 | while True: 30 | url = f"https://api.github.com/repos/home-assistant/core/tags?per_page=100&page={page}" 31 | with urllib.request.urlopen(url) as response: # noqa: S310 32 | tags = json.loads(response.read().decode()) 33 | 34 | if not tags: 35 | break 36 | 37 | all_tags.extend(tags) 38 | 39 | # Check if we've gone far enough back 40 | # Stop if we've found versions older than our minimum 41 | oldest_in_page = None 42 | for t in tags: 43 | if re.match(r"^\d+\.\d+\.\d+$", t["name"]): 44 | parts = t["name"].split(".") 45 | year, month = int(parts[0]), int(parts[1]) 46 | if oldest_in_page is None or (year, month) < oldest_in_page: 47 | oldest_in_page = (year, month) 48 | 49 | if oldest_in_page and oldest_in_page < MIN_VERSION: 50 | break 51 | 52 | page += 1 53 | if page > 10: # Safety limit 54 | break 55 | 56 | # Filter to stable releases only (no beta/rc) 57 | stable_pattern = re.compile(r"^\d+\.\d+\.\d+$") 58 | versions = [t["name"] for t in all_tags if stable_pattern.match(t["name"])] 59 | 60 | # Group by minor version and get latest patch for each 61 | latest: dict[str, str] = {} 62 | for version in versions: 63 | parts = version.split(".") 64 | year, month = int(parts[0]), int(parts[1]) 65 | # Only include versions >= MIN_VERSION 66 | if (year, month) >= MIN_VERSION: 67 | minor_key = f"{parts[0]}.{parts[1]}" 68 | # Keep the one with highest patch number 69 | if minor_key not in latest: 70 | latest[minor_key] = version 71 | else: 72 | current_patch = int(latest[minor_key].split(".")[2]) 73 | new_patch = int(parts[2]) 74 | if new_patch > current_patch: 75 | latest[minor_key] = version 76 | 77 | # Sort by version 78 | return sorted(latest.values(), key=lambda v: [int(x) for x in v.split(".")]) 79 | 80 | 81 | def get_python_version(ha_version: str) -> str: 82 | """Determine Python version based on HA Core version.""" 83 | parts = ha_version.split(".") 84 | year, month = int(parts[0]), int(parts[1]) 85 | # 2024.x and 2025.1 use Python 3.12, 2025.2+ use Python 3.13 86 | if year == 2024 or (year == 2025 and month == 1): 87 | return "3.12" 88 | return "3.13" 89 | 90 | 91 | def generate_matrix_yaml(versions: list[str]) -> str: 92 | """Generate the YAML matrix include block.""" 93 | lines = [] 94 | for version in versions: 95 | python_ver = get_python_version(version) 96 | lines.append(f' - core-version: "{version}"') 97 | lines.append(f' python-version: "{python_ver}"') 98 | # Add dev version 99 | lines.append(' - core-version: "dev"') 100 | lines.append(' python-version: "3.13"') 101 | return "\n".join(lines) 102 | 103 | 104 | def update_workflow_file(workflow_path: Path, new_matrix: str) -> bool: 105 | """Update the workflow file with new matrix. Returns True if changed.""" 106 | content = workflow_path.read_text() 107 | 108 | # Pattern to match the matrix include block 109 | # Matches from "include:" to just before " steps:" 110 | pattern = re.compile( 111 | r"( include:\n)(.*?)( steps:)", 112 | re.DOTALL, 113 | ) 114 | 115 | def replacer(match: re.Match) -> str: 116 | return f"{match.group(1)}{new_matrix}\n{match.group(3)}" 117 | 118 | new_content = pattern.sub(replacer, content) 119 | 120 | if new_content == content: 121 | return False 122 | 123 | workflow_path.write_text(new_content) 124 | return True 125 | 126 | 127 | def main() -> None: 128 | """Main entry point.""" 129 | print("Fetching latest HA Core versions...") # noqa: T201 130 | versions = get_ha_core_versions() 131 | print(f"Found {len(versions)} versions: {', '.join(versions)}") # noqa: T201 132 | 133 | print("\nGenerating matrix...") # noqa: T201 134 | matrix = generate_matrix_yaml(versions) 135 | print(matrix) # noqa: T201 136 | 137 | workflow_path = Path(__file__).parent.parent / ".github/workflows/pytest.yaml" 138 | print(f"\nUpdating {workflow_path}...") # noqa: T201 139 | 140 | if update_workflow_file(workflow_path, matrix): 141 | print("Workflow updated successfully!") # noqa: T201 142 | else: 143 | print("No changes needed.") # noqa: T201 144 | 145 | 146 | if __name__ == "__main__": 147 | main() 148 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Adaptive Lighting integration.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant import config_entries 8 | from homeassistant.const import CONF_NAME 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig 11 | 12 | from .const import ( # pylint: disable=unused-import 13 | CONF_LIGHTS, 14 | DOMAIN, 15 | EXTRA_VALIDATION, 16 | NONE_STR, 17 | VALIDATION_TUPLES, 18 | ) 19 | from .switch import validate 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 25 | """Handle a config flow for Adaptive Lighting.""" 26 | 27 | VERSION = 1 28 | 29 | source_options: dict[str, Any] | None = None 30 | 31 | async def async_step_user(self, user_input: dict[str, Any] | None = None): 32 | """Handle the initial step.""" 33 | if user_input is None and self._async_current_entries(): 34 | return await self.async_step_menu() 35 | return await self.async_step_wait_for_name(user_input) 36 | 37 | async def async_step_menu(self, user_input: dict[str, Any] | None = None): 38 | """Handle the menu step.""" 39 | if user_input is not None: 40 | if user_input["action"] != "new": 41 | entry_id = user_input["action"] 42 | entry = self.hass.config_entries.async_get_entry(entry_id) 43 | if entry: 44 | self.source_options = dict(entry.options) 45 | return await self.async_step_wait_for_name() 46 | 47 | entries = self._async_current_entries() 48 | options = {"new": "Create new instance"} 49 | for entry in entries: 50 | options[entry.entry_id] = f"Duplicate '{entry.title}'" 51 | 52 | return self.async_show_form( 53 | step_id="menu", 54 | data_schema=vol.Schema( 55 | {vol.Required("action", default="new"): vol.In(options)}, 56 | ), 57 | ) 58 | 59 | async def async_step_wait_for_name(self, user_input: dict[str, Any] | None = None): 60 | """Handle the name step.""" 61 | errors: dict[str, str] = {} 62 | 63 | if user_input is not None: 64 | await self.async_set_unique_id(user_input[CONF_NAME]) 65 | self._abort_if_unique_id_configured() 66 | options = self.source_options 67 | return self.async_create_entry( 68 | title=user_input[CONF_NAME], 69 | data=user_input, 70 | options=options, 71 | ) 72 | 73 | return self.async_show_form( 74 | step_id="user", 75 | data_schema=vol.Schema({vol.Required(CONF_NAME): str}), 76 | errors=errors, 77 | ) 78 | 79 | async def async_step_import(self, user_input: dict[str, Any] | None = None): 80 | """Handle configuration by YAML file.""" 81 | if user_input is None: 82 | return self.async_abort(reason="no_data") 83 | 84 | await self.async_set_unique_id(user_input[CONF_NAME]) 85 | # Keep a list of switches that are configured via YAML 86 | data = self.hass.data.setdefault(DOMAIN, {}) 87 | data.setdefault("__yaml__", set()).add(self.unique_id) 88 | 89 | for entry in self._async_current_entries(): 90 | if entry.unique_id == self.unique_id: 91 | self.hass.config_entries.async_update_entry(entry, data=user_input) 92 | self._abort_if_unique_id_configured() 93 | 94 | return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) 95 | 96 | @staticmethod 97 | @callback 98 | def async_get_options_flow( 99 | config_entry: config_entries.ConfigEntry, # noqa: ARG004 100 | ) -> "OptionsFlowHandler": 101 | """Get the options flow for this handler.""" 102 | return OptionsFlowHandler() 103 | 104 | 105 | def validate_options(user_input: dict[str, Any], errors: dict[str, str]) -> None: 106 | """Validate the options in the OptionsFlow. 107 | 108 | This is an extra validation step because the validators 109 | in `EXTRA_VALIDATION` cannot be serialized to json. 110 | """ 111 | for key, (_validate, _) in EXTRA_VALIDATION.items(): 112 | # these are unserializable validators 113 | value = user_input.get(key) 114 | try: 115 | if value is not None and value != NONE_STR: 116 | _validate(value) 117 | except vol.Invalid: 118 | _LOGGER.exception("Configuration option %s=%s is incorrect", key, value) 119 | errors["base"] = "option_error" 120 | 121 | 122 | class OptionsFlowHandler(config_entries.OptionsFlow): 123 | """Handle a option flow for Adaptive Lighting.""" 124 | 125 | async def async_step_init(self, user_input: dict[str, Any] | None = None): 126 | """Handle options flow.""" 127 | conf = self.config_entry 128 | data = validate(conf) 129 | if conf.source == config_entries.SOURCE_IMPORT: 130 | return self.async_show_form(step_id="init", data_schema=None) 131 | errors: dict[str, str] = {} 132 | if user_input is not None: 133 | validate_options(user_input, errors) 134 | if not errors: 135 | return self.async_create_entry(title="", data=user_input) 136 | 137 | # Validate that all configured lights still exist 138 | all_lights = set(self.hass.states.async_entity_ids("light")) 139 | for configured_light in data[CONF_LIGHTS]: 140 | if configured_light not in all_lights: 141 | errors = {CONF_LIGHTS: "entity_missing"} 142 | _LOGGER.error( 143 | "%s: light entity %s is configured, but was not found", 144 | data[CONF_NAME], 145 | configured_light, 146 | ) 147 | 148 | to_replace: dict[str, Any] = { 149 | CONF_LIGHTS: EntitySelector( 150 | EntitySelectorConfig( 151 | domain="light", 152 | multiple=True, 153 | ), 154 | ), 155 | } 156 | 157 | options_schema = {} 158 | for name, default, validation in VALIDATION_TUPLES: 159 | key = vol.Optional(name, default=conf.options.get(name, default)) 160 | value = to_replace.get(name, validation) 161 | options_schema[key] = value 162 | 163 | return self.async_show_form( 164 | step_id="init", 165 | data_schema=vol.Schema(options_schema), 166 | errors=errors, 167 | ) 168 | -------------------------------------------------------------------------------- /tests/test_color_and_brightness.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import zoneinfo 3 | 4 | import pytest 5 | from astral import LocationInfo 6 | from astral.location import Location 7 | from homeassistant.components.adaptive_lighting.color_and_brightness import ( 8 | SunEvent, 9 | SunEvents, 10 | ) 11 | 12 | # Create a mock astral_location object 13 | location = Location(LocationInfo()) 14 | 15 | LAT_LONG_TZS = [ 16 | (52.379189, 4.899431, "Europe/Amsterdam"), 17 | (32.87336, -117.22743, "US/Pacific"), 18 | (60, 50, "GMT"), 19 | (60, 50, "UTC"), 20 | ] 21 | 22 | 23 | @pytest.fixture(params=LAT_LONG_TZS) 24 | def tzinfo_and_location(request): 25 | lat, long, timezone = request.param 26 | tzinfo = zoneinfo.ZoneInfo(timezone) 27 | location = Location( 28 | LocationInfo( 29 | name="name", 30 | region="region", 31 | timezone=timezone, 32 | latitude=lat, 33 | longitude=long, 34 | ), 35 | ) 36 | return tzinfo, location 37 | 38 | 39 | def test_replace_time(tzinfo_and_location): 40 | tzinfo, location = tzinfo_and_location 41 | sun_events = SunEvents( 42 | name="test", 43 | astral_location=location, 44 | sunrise_time=None, 45 | min_sunrise_time=None, 46 | max_sunrise_time=None, 47 | sunset_time=None, 48 | min_sunset_time=None, 49 | max_sunset_time=None, 50 | timezone=tzinfo, 51 | ) 52 | 53 | new_time = dt.time(5, 30) 54 | datetime = dt.datetime(2022, 1, 1) 55 | replaced_time_utc = sun_events._replace_time(datetime.date(), new_time) 56 | assert replaced_time_utc.astimezone(tzinfo).time() == new_time 57 | 58 | 59 | def test_sunrise_without_offset(tzinfo_and_location): 60 | tzinfo, location = tzinfo_and_location 61 | 62 | sun_events = SunEvents( 63 | name="test", 64 | astral_location=location, 65 | sunrise_time=None, 66 | min_sunrise_time=None, 67 | max_sunrise_time=None, 68 | sunset_time=None, 69 | min_sunset_time=None, 70 | max_sunset_time=None, 71 | timezone=tzinfo, 72 | ) 73 | date = dt.datetime(2022, 1, 1).date() 74 | result = sun_events.sunrise(date) 75 | assert result == location.sunrise(date) 76 | 77 | 78 | def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location): 79 | tzinfo, location = tzinfo_and_location 80 | sun_events = SunEvents( 81 | name="test", 82 | astral_location=location, 83 | sunrise_time=None, 84 | min_sunrise_time=None, 85 | max_sunrise_time=None, 86 | sunset_time=None, 87 | min_sunset_time=None, 88 | max_sunset_time=None, 89 | timezone=tzinfo, 90 | ) 91 | date = dt.datetime(2022, 1, 1).date() 92 | sunset = location.sunset(date) 93 | position = sun_events.sun_position(sunset) 94 | assert position == 0 95 | sunrise = location.sunrise(date) 96 | position = sun_events.sun_position(sunrise) 97 | assert position == 0 98 | noon = location.noon(date) 99 | position = sun_events.sun_position(noon) 100 | assert position == 1 101 | midnight = location.midnight(date) 102 | position = sun_events.sun_position(midnight) 103 | assert position == -1 104 | 105 | 106 | def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location): 107 | tzinfo, location = tzinfo_and_location 108 | sun_events = SunEvents( 109 | name="test", 110 | astral_location=location, 111 | sunrise_time=dt.time(6, 0), 112 | min_sunrise_time=None, 113 | max_sunrise_time=None, 114 | sunset_time=dt.time(18, 0), 115 | min_sunset_time=None, 116 | max_sunset_time=None, 117 | timezone=tzinfo, 118 | ) 119 | date = dt.datetime(2022, 1, 1).date() 120 | sunset = sun_events.sunset(date) 121 | position = sun_events.sun_position(sunset) 122 | assert position == 0 123 | sunrise = sun_events.sunrise(date) 124 | position = sun_events.sun_position(sunrise) 125 | assert position == 0 126 | noon, midnight = sun_events.noon_and_midnight(date) 127 | position = sun_events.sun_position(noon) 128 | assert position == 1 129 | position = sun_events.sun_position(midnight) 130 | assert position == -1 131 | 132 | 133 | def test_noon_and_midnight(tzinfo_and_location): 134 | tzinfo, location = tzinfo_and_location 135 | sun_events = SunEvents( 136 | name="test", 137 | astral_location=location, 138 | sunrise_time=None, 139 | min_sunrise_time=None, 140 | max_sunrise_time=None, 141 | sunset_time=None, 142 | min_sunset_time=None, 143 | max_sunset_time=None, 144 | timezone=tzinfo, 145 | ) 146 | date = dt.datetime(2022, 1, 1) 147 | noon, midnight = sun_events.noon_and_midnight(date) 148 | assert noon == location.noon(date) 149 | assert midnight == location.midnight(date) 150 | 151 | 152 | def test_sun_events(tzinfo_and_location): 153 | tzinfo, location = tzinfo_and_location 154 | sun_events = SunEvents( 155 | name="test", 156 | astral_location=location, 157 | sunrise_time=None, 158 | min_sunrise_time=None, 159 | max_sunrise_time=None, 160 | sunset_time=None, 161 | min_sunset_time=None, 162 | max_sunset_time=None, 163 | timezone=tzinfo, 164 | ) 165 | 166 | date = dt.datetime(2022, 1, 1) 167 | events = sun_events.sun_events(date) 168 | assert len(events) == 4 169 | assert (SunEvent.SUNRISE, location.sunrise(date).timestamp()) in events 170 | 171 | 172 | def test_prev_and_next_events(tzinfo_and_location): 173 | tzinfo, location = tzinfo_and_location 174 | sun_events = SunEvents( 175 | name="test", 176 | astral_location=location, 177 | sunrise_time=None, 178 | min_sunrise_time=None, 179 | max_sunrise_time=None, 180 | sunset_time=None, 181 | min_sunset_time=None, 182 | max_sunset_time=None, 183 | timezone=tzinfo, 184 | ) 185 | datetime = dt.datetime(2022, 1, 1, 10, 0) 186 | after_sunrise = sun_events.sunrise(datetime.date()) + dt.timedelta(hours=1) 187 | prev_event, next_event = sun_events.prev_and_next_events(after_sunrise) 188 | assert prev_event[0] == SunEvent.SUNRISE 189 | assert next_event[0] == SunEvent.NOON 190 | 191 | 192 | def test_closest_event(tzinfo_and_location): 193 | tzinfo, location = tzinfo_and_location 194 | sun_events = SunEvents( 195 | name="test", 196 | astral_location=location, 197 | sunrise_time=None, 198 | min_sunrise_time=None, 199 | max_sunrise_time=None, 200 | sunset_time=None, 201 | min_sunset_time=None, 202 | max_sunset_time=None, 203 | timezone=tzinfo, 204 | ) 205 | datetime = dt.datetime(2022, 1, 1, 6, 0) 206 | sunrise = sun_events.sunrise(datetime.date()) 207 | event_name, ts = sun_events.closest_event(sunrise) 208 | assert event_name == SunEvent.SUNRISE 209 | assert ts == location.sunrise(sunrise.date()).timestamp() 210 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/LICENSE.md: -------------------------------------------------------------------------------- 1 | This license **only** applies to all files in `custom_components/adaptive_lighting/translations/` in the Adaptive Lighting repository. 2 | For these translations we wave copyright and related rights through the CC0 1.0 Universal license. 3 | 4 | # Creative Commons CC0 1.0 Universal 5 | 6 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. 7 | 8 | ### Statement of Purpose 9 | 10 | The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). 11 | 12 | Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. 13 | 14 | For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 15 | 16 | 1. **Copyright and Related Rights.** A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: 17 | 18 | i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; 19 | 20 | ii. moral rights retained by the original author(s) and/or performer(s); 21 | 22 | iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; 23 | 24 | iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; 25 | 26 | v. rights protecting the extraction, dissemination, use and reuse of data in a Work; 27 | 28 | vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and 29 | 30 | vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 31 | 32 | 2. **Waiver.** To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 33 | 34 | 3. **Public License Fallback.** Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 35 | 36 | 4. **Limitations and Disclaimers.** 37 | 38 | a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. 39 | 40 | b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. 41 | 42 | c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. 43 | 44 | d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. 45 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/sl.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "step": { 4 | "init": { 5 | "data": { 6 | "prefer_rgb_color": "prefer_rgb_color: Ali v primeru možnosti raje uporabiti prilagoditev RGB barve kot barvno temperaturo luči. 🌈", 7 | "transition_until_sleep": "transition_until_sleep: Če je omogočeno, bo Adaptive Lighting obravnaval nastavitve spanja kot minimalne vrednosti in bo po zahodu sonca prehajal na te vrednosti. 🌙", 8 | "take_over_control": "take_over_control: Onemogoči Adaptive Lighting, če drug vir pokliče \"light.turn_on\", ko so luči prižgane in se prilagajajo. Opozorilo: to ob vsakem intervalu kliče \"homeassistant.update_entity\"! 🔒", 9 | "detect_non_ha_changes": "„detect_non_ha_changes: Zazna in ustavi prilagoditve za spremembe stanja, ki niso posledica \"light.turn_on\". Zahteva omogočeno \"take_over_control\". 🕵️ Pozor: ⚠️ Nekatere luči lahko nepravilno poročajo, da so prižgane, kar lahko povzroči nepričakovano vklapljanje. Onemogočite to funkcijo, če naletite na takšne težave.", 10 | "lights": "lights: Seznam entity_id-jev luči za nadzor (lahko je prazen). 🌟", 11 | "min_brightness": "min_brightness: Odstotek najmanjše svetlosti. 💡", 12 | "max_brightness": "max_brightness: Odstotek največeje svetlosti. 💡", 13 | "min_color_temp": "min_color_temp: Najtoplejša barvna temperatura v Kelvinih. 🔥", 14 | "max_color_temp": "max_color_temp: Najhladnejša barvna temperatura v kelvinih. ❄️", 15 | "separate_turn_on_commands": "separate_turn_on_commands: Uporabi ločene klice \"light.turn_on\" za barvo in jakost, kar je potrebno za nekatere tipe luči. 🔀", 16 | "skip_redundant_commands": "skip_redundant_commands: Preskoči pošiljanje prilagoditvenih ukazov, če je ciljano stanje že enako poznanemu stanju luči. Zmanjšuje omrežni promet in izboljšuje odzivnost prilagajanja v določenih situacijah. 📉 Onemogočite, če se fizična stanja luči ne ujemajo z zabeleženim stanjem v HA.", 17 | "intercept": "intercept: Prestreza in prilagaja klice \"light.turn_on\" za takojšnjo prilagoditev barve in jakosti. 🏎️ Onemogočite za luči, ki ne podpirajo \"light.turn_on\" z barvo in svetlostjo.", 18 | "multi_light_intercept": "multi_light_intercept: Prestreza in prilagaja klice \"light.turn_on\", ki ciljajo več luči. ➗⚠️ To lahko privede do razdelitve enega klica \"light.turn_on\" v več klicev, npr. ko so luči na različnih stikalih. Zahteva omogočeno \"intercept\".", 19 | "include_config_in_attributes": "include_config_in_attributes: Ko je nastavljeno na \"true\", prikaže vse možnosti kot atribute stikala v Home Assistantu. 📝", 20 | "only_once": "only_once: Prilagodi luči samo ob vklopu (true) ali pa jih še naprej prilagajaj (false). 🔄", 21 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: Ob začetnem vklopu luči. Če je nastavljeno na \"true\", AL prilagodi samo, če je \"light.turn_on\" klic brez podanih parametrov barve ali jakosti. ❌🌈 S tem se npr. prepreči prilagajanje pri aktivaciji scene. Če je \"false\", AL prilagodi ne glede na prisotnost barve ali jakosti v začetnih \"service_data\". Zahteva omogočeno \"take_over_control\". 🕵️" 22 | }, 23 | "data_description": { 24 | "sunrise_offset": "Prilagodite čas sončnega vzhoda z pozitivnim ali negativnim zamikom v sekundah. ⏰", 25 | "send_split_delay": "Zamik (ms) med \"separate_turn_on_commands\" za luči, ki ne podpirajo hkratne nastavitve jakosti in barve. ⏲️", 26 | "transition": "Trajanje prehoda pri spreminjanju luči, v sekundah. 🕑", 27 | "interval": "Pogostost prilagajanja luči, v sekundah. 🔄", 28 | "sleep_brightness": "Odstotek svetlosti luči v načinu spanja. 😴", 29 | "sleep_rgb_or_color_temp": "V načinu spanja uporabi \"rgb_color\" ali \"color_temp\". 🌙", 30 | "sleep_color_temp": "Barvna temperatura v načinu spanja (uporabljena, ko je \"sleep_rgb_or_color_temp\" nastavljena na \"color_temp\") v Kelvinih. 😴", 31 | "sleep_transition": "Trajanje prehoda, ko se preklopi način spanja, v sekundah. 😴", 32 | "sunrise_time": "Nastavi fiksni čas (HH:MM:SS) sončnega vzhoda. 🌅", 33 | "min_sunrise_time": "Nastavi najzgodnejši navidezni sončni vzhod (HH:MM:SS), dovoljuje kasnejše vzhode. 🌅", 34 | "sunset_time": "Nastavite fiksni čas (HH:MM:SS) za sončni zahod. 🌇", 35 | "min_sunset_time": "Nastavite najzgodnejši navidezni čas sončnega zahoda (HH:MM:SS), dovoljuje kasnejše sončne zahode. 🌇", 36 | "sunset_offset": "Prilagodite čas sončnega zahoda s pozitivnim ali negativnim zamikom v sekundah. ⏰", 37 | "brightness_mode_time_dark": "(Prezrto, če je \"brightness_mode='default'\") Trajanje v sekundah za postopno povečanje ali zmanjšanje svetlosti pred/po sončnem vzhodu/zahodu. 📈📉", 38 | "brightness_mode_time_light": "(Prezrto, če je \"brightness_mode='default'\") Trajanje v sekundah za postopno povečanje ali zmanjšanje svetlosti po/pred sončnem vzhodu/zahodu. 📈📉", 39 | "autoreset_control_seconds": "Samodejno ponastavi ročni nadzor po določenem številu sekund. Nastavite na 0, da onemogočite. ⏲️", 40 | "max_sunrise_time": "Nastavi najkasnejši virtualni sončni vzhod (HH:MM:SS), dovoljuje zgodnejše vzhode. 🌅", 41 | "max_sunset_time": "Nastavite najpoznejši navidezni čas sončnega zahoda (HH:MM:SS), dovoljuje zgodnejše sončne zahode. 🌇", 42 | "brightness_mode": "Način upravljanja svetlosti. Možne vrednosti so \"default\", \"linear\" in \"tanh\" (uporablja \"brightness_mode_time_dark\" in \"brightness_mode_time_light\"). 📈", 43 | "initial_transition": "Trajanje prvega prehoda, ko se luči prižgejo (iz \"off\" v \"on\"), v sekundah. ⏲️", 44 | "sleep_rgb_color": "RGB barva v načinu spanja (uporabljena, ko je \"sleep_rgb_or_color_temp\" nastavljeno na \"rgb_color\"). 🌈" 45 | }, 46 | "title": "Nastavitve prilagodljive osvetlitve", 47 | "description": "Konfigurirajte komponento Adaptive Lighting. Imena možnosti so usklajena z nastavitvami v YAML. Če ste ta vnos definirali v YAML, tukaj ne bodo prikazane nobene možnosti. Za interaktivne grafe, ki ponazarjajo učinke parametrov, obiščite to [spletno aplikacijo](https://basnijholt.github.io/adaptive-lighting). Za dodatne podrobnosti glejte [uradno dokumentacijo](https://github.com/basnijholt/adaptive-lighting#readme)." 48 | } 49 | } 50 | }, 51 | "config": { 52 | "step": { 53 | "user": { 54 | "title": "Izberite ime za instanco Adaptive Lighting", 55 | "description": "Vsaka instanca lahko vsebuje več luči!" 56 | } 57 | }, 58 | "abort": { 59 | "already_configured": "Naprava je že konfigurirana" 60 | } 61 | }, 62 | "services": { 63 | "change_switch_settings": { 64 | "fields": { 65 | "only_once": { 66 | "description": "Prilagajaj luči samo kadar so prižgane (\"true\") ali konstantno jih prilagajaj (\"false\")" 67 | }, 68 | "max_sunrise_time": { 69 | "description": "Nastavite najpoznejši navidezni čas sončnega vzhoda (HH:MM:SS), dovoljuje zgodnejše sončne vzhode. 🌅" 70 | } 71 | } 72 | } 73 | }, 74 | "title": "Prilagodljiva osvetlitev" 75 | } 76 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/adaptation_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for adaptation commands.""" 2 | 3 | import logging 4 | from collections.abc import AsyncGenerator 5 | from dataclasses import dataclass 6 | from typing import Any, Literal 7 | 8 | from homeassistant.components.light import ( 9 | ATTR_BRIGHTNESS, 10 | ATTR_BRIGHTNESS_PCT, 11 | ATTR_BRIGHTNESS_STEP, 12 | ATTR_BRIGHTNESS_STEP_PCT, 13 | ATTR_COLOR_NAME, 14 | ATTR_COLOR_TEMP_KELVIN, 15 | ATTR_HS_COLOR, 16 | ATTR_RGB_COLOR, 17 | ATTR_RGBW_COLOR, 18 | ATTR_RGBWW_COLOR, 19 | ATTR_TRANSITION, 20 | ATTR_XY_COLOR, 21 | ) 22 | from homeassistant.const import ATTR_ENTITY_ID 23 | from homeassistant.core import Context, HomeAssistant, State 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | COLOR_ATTRS = { # Should ATTR_PROFILE be in here? 28 | ATTR_COLOR_NAME, 29 | ATTR_COLOR_TEMP_KELVIN, 30 | ATTR_HS_COLOR, 31 | ATTR_RGB_COLOR, 32 | ATTR_XY_COLOR, 33 | ATTR_RGBW_COLOR, 34 | ATTR_RGBWW_COLOR, 35 | } 36 | 37 | 38 | BRIGHTNESS_ATTRS = { 39 | ATTR_BRIGHTNESS, 40 | ATTR_BRIGHTNESS_PCT, 41 | ATTR_BRIGHTNESS_STEP, 42 | ATTR_BRIGHTNESS_STEP_PCT, 43 | } 44 | 45 | ServiceData = dict[str, Any] 46 | 47 | 48 | def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: 49 | """Splits the service data by the adapted attributes. 50 | 51 | i.e., into separate data items for brightness and color. 52 | """ 53 | common_attrs = {ATTR_ENTITY_ID} 54 | common_data = {k: service_data[k] for k in common_attrs if k in service_data} 55 | 56 | attributes_split_sequence = [BRIGHTNESS_ATTRS, COLOR_ATTRS] 57 | service_datas: list[dict[str, Any]] = [] 58 | 59 | for attributes in attributes_split_sequence: 60 | split_data = { 61 | attribute: service_data[attribute] 62 | for attribute in attributes 63 | if service_data.get(attribute) 64 | } 65 | if split_data: 66 | service_datas.append(common_data | split_data) 67 | 68 | # Distribute the transition duration across all service calls 69 | if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None: 70 | transition /= len(service_datas) 71 | 72 | for _service_data in service_datas: 73 | _service_data[ATTR_TRANSITION] = transition 74 | 75 | return service_datas 76 | 77 | 78 | def _remove_redundant_attributes( 79 | service_data: ServiceData, 80 | state: State, 81 | ) -> ServiceData: 82 | """Filter service data by removing attributes that already equal the given state. 83 | 84 | Removes all attributes from service call data whose values are already present 85 | in the target entity's state. 86 | """ 87 | attributes: dict[str, Any] = dict(state.attributes) 88 | return { 89 | k: v 90 | for k, v in service_data.items() 91 | if k not in attributes or v != attributes[k] 92 | } 93 | 94 | 95 | def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool: 96 | """Determines whether the service data justifies an adaptation service call. 97 | 98 | A service call is not justified for data which does not contain any entries that 99 | change relevant attributes of an adapting entity, e.g., brightness or color. 100 | """ 101 | common_attrs = {ATTR_ENTITY_ID, ATTR_TRANSITION} 102 | 103 | return any(attr not in common_attrs for attr in service_data) 104 | 105 | 106 | async def _create_service_call_data_iterator( 107 | hass: HomeAssistant, 108 | service_datas: list[ServiceData], 109 | filter_by_state: bool, 110 | ) -> AsyncGenerator[ServiceData]: 111 | """Enumerates and filters a list of service datas on the fly. 112 | 113 | If filtering is enabled, every service data is filtered by the current state of 114 | the related entity and only returned if it contains relevant data that justifies 115 | a service call. 116 | The main advantage of this generator over a list is that it applies the filter 117 | at the time when the service data is read instead of up front. This gives greater 118 | flexibility because entity states can change while the items are iterated. 119 | """ 120 | for service_data in service_datas: 121 | if filter_by_state and (entity_id := service_data.get(ATTR_ENTITY_ID)): 122 | current_entity_state = hass.states.get(entity_id) 123 | 124 | # Filter data to remove attributes that equal the current state 125 | if current_entity_state is not None: 126 | service_data = _remove_redundant_attributes( # noqa: PLW2901 127 | service_data, 128 | state=current_entity_state, 129 | ) 130 | 131 | # Emit service data if it still contains relevant attributes (else try next) 132 | if _has_relevant_service_data_attributes(service_data): 133 | yield service_data 134 | else: 135 | yield service_data 136 | 137 | 138 | @dataclass 139 | class AdaptationData: 140 | """Holds all data required to execute an adaptation.""" 141 | 142 | entity_id: str 143 | context: Context 144 | sleep_time: float 145 | service_call_datas: AsyncGenerator[ServiceData] 146 | force: bool 147 | max_length: int 148 | which: Literal["brightness", "color", "both"] 149 | initial_sleep: bool = False 150 | 151 | async def next_service_call_data(self) -> ServiceData | None: 152 | """Return data for the next service call, or none if no more data exists.""" 153 | return await anext(self.service_call_datas, None) 154 | 155 | def __str__(self) -> str: 156 | """Return a string representation of the data.""" 157 | return ( 158 | f"{self.__class__.__name__}(" 159 | f"entity_id={self.entity_id}, " 160 | f"context_id={self.context.id}, " 161 | f"sleep_time={self.sleep_time}, " 162 | f"force={self.force}, " 163 | f"max_length={self.max_length}, " 164 | f"which={self.which}, " 165 | f"initial_sleep={self.initial_sleep}" 166 | ")" 167 | ) 168 | 169 | 170 | class NoColorOrBrightnessInServiceDataError(Exception): 171 | """Exception raised when no color or brightness attributes are found in service data.""" 172 | 173 | 174 | def _identify_lighting_type( 175 | service_data: ServiceData, 176 | ) -> Literal["brightness", "color", "both"]: 177 | """Extract the 'which' attribute from the service data.""" 178 | has_brightness = ATTR_BRIGHTNESS in service_data 179 | has_color = any(attr in service_data for attr in COLOR_ATTRS) 180 | if has_brightness and has_color: 181 | return "both" 182 | if has_brightness: 183 | return "brightness" 184 | if has_color: 185 | return "color" 186 | msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}" 187 | raise NoColorOrBrightnessInServiceDataError(msg) 188 | 189 | 190 | def prepare_adaptation_data( 191 | hass: HomeAssistant, 192 | entity_id: str, 193 | context: Context, 194 | transition: float | None, 195 | split_delay: float, 196 | service_data: ServiceData, 197 | split: bool, 198 | filter_by_state: bool, 199 | force: bool, 200 | ) -> AdaptationData: 201 | """Prepares a data object carrying all data required to execute an adaptation.""" 202 | _LOGGER.debug( 203 | "Preparing adaptation data for %s with service data %s", 204 | entity_id, 205 | service_data, 206 | ) 207 | service_datas = _split_service_call_data(service_data) if split else [service_data] 208 | 209 | service_datas_length = len(service_datas) 210 | 211 | if transition is not None: 212 | transition_duration_per_data = transition / max(1, service_datas_length) 213 | sleep_time = transition_duration_per_data + split_delay 214 | else: 215 | sleep_time = split_delay 216 | 217 | service_data_iterator = _create_service_call_data_iterator( 218 | hass, 219 | service_datas, 220 | filter_by_state, 221 | ) 222 | 223 | lighting_type = _identify_lighting_type(service_data) 224 | 225 | return AdaptationData( 226 | entity_id=entity_id, 227 | context=context, 228 | sleep_time=sleep_time, 229 | service_call_datas=service_data_iterator, 230 | force=force, 231 | max_length=service_datas_length, 232 | which=lighting_type, 233 | ) 234 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test Adaptive Lighting config flow.""" 2 | 3 | from homeassistant.components.adaptive_lighting.const import ( 4 | CONF_SUNRISE_TIME, 5 | CONF_SUNSET_TIME, 6 | DEFAULT_NAME, 7 | DOMAIN, 8 | NONE_STR, 9 | VALIDATION_TUPLES, 10 | ) 11 | from homeassistant.config_entries import SOURCE_IMPORT 12 | from homeassistant.const import CONF_NAME 13 | from homeassistant.data_entry_flow import FlowResultType 14 | 15 | from tests.common import MockConfigEntry 16 | 17 | DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES} 18 | 19 | 20 | async def test_flow_manual_configuration(hass): 21 | """Test that config flow works.""" 22 | result = await hass.config_entries.flow.async_init( 23 | DOMAIN, 24 | context={"source": "user"}, 25 | ) 26 | 27 | assert result["type"] == FlowResultType.FORM 28 | assert result["step_id"] == "user" 29 | assert result["handler"] == "adaptive_lighting" 30 | 31 | result = await hass.config_entries.flow.async_configure( 32 | result["flow_id"], 33 | user_input={CONF_NAME: "living room"}, 34 | ) 35 | assert result["type"] == FlowResultType.CREATE_ENTRY 36 | assert result["title"] == "living room" 37 | 38 | 39 | async def test_import_success(hass): 40 | """Test import step is successful.""" 41 | data = DEFAULT_DATA.copy() 42 | data[CONF_NAME] = DEFAULT_NAME 43 | result = await hass.config_entries.flow.async_init( 44 | DOMAIN, 45 | context={"source": "import"}, 46 | data=data, 47 | ) 48 | 49 | assert result["type"] == FlowResultType.CREATE_ENTRY 50 | assert result["title"] == DEFAULT_NAME 51 | for key, value in data.items(): 52 | assert result["data"][key] == value 53 | 54 | 55 | async def test_options(hass): 56 | """Test updating options.""" 57 | entry = MockConfigEntry( 58 | domain=DOMAIN, 59 | title=DEFAULT_NAME, 60 | data={CONF_NAME: DEFAULT_NAME}, 61 | options={}, 62 | ) 63 | entry.add_to_hass(hass) 64 | 65 | await hass.config_entries.async_setup(entry.entry_id) 66 | 67 | result = await hass.config_entries.options.async_init(entry.entry_id) 68 | assert result["type"] == FlowResultType.FORM 69 | assert result["step_id"] == "init" 70 | 71 | data = DEFAULT_DATA.copy() 72 | data[CONF_SUNRISE_TIME] = NONE_STR 73 | data[CONF_SUNSET_TIME] = NONE_STR 74 | result = await hass.config_entries.options.async_configure( 75 | result["flow_id"], 76 | user_input=data, 77 | ) 78 | assert result["type"] == FlowResultType.CREATE_ENTRY 79 | for key, value in data.items(): 80 | assert result["data"][key] == value 81 | 82 | 83 | async def test_incorrect_options(hass): 84 | """Test updating incorrect options.""" 85 | entry = MockConfigEntry( 86 | domain=DOMAIN, 87 | title=DEFAULT_NAME, 88 | data={CONF_NAME: DEFAULT_NAME}, 89 | options={}, 90 | ) 91 | entry.add_to_hass(hass) 92 | 93 | await hass.config_entries.async_setup(entry.entry_id) 94 | 95 | result = await hass.config_entries.options.async_init(entry.entry_id) 96 | data = DEFAULT_DATA.copy() 97 | data[CONF_SUNRISE_TIME] = "yolo" 98 | data[CONF_SUNSET_TIME] = "yolo" 99 | result = await hass.config_entries.options.async_configure( 100 | result["flow_id"], 101 | user_input=data, 102 | ) 103 | 104 | 105 | async def test_import_twice(hass): 106 | """Test importing twice.""" 107 | data = DEFAULT_DATA.copy() 108 | data[CONF_NAME] = DEFAULT_NAME 109 | for _ in range(2): 110 | _ = await hass.config_entries.flow.async_init( 111 | DOMAIN, 112 | context={"source": "import"}, 113 | data=data, 114 | ) 115 | 116 | 117 | async def test_options_flow_for_yaml_import(hass): 118 | """Test that options flow for YAML-imported entries shows empty form. 119 | 120 | When a config entry is imported from YAML (source=SOURCE_IMPORT), 121 | the options flow should show an empty form since the user should 122 | modify the YAML configuration directly, not through the UI. 123 | """ 124 | entry = MockConfigEntry( 125 | domain=DOMAIN, 126 | title=DEFAULT_NAME, 127 | data={CONF_NAME: DEFAULT_NAME}, 128 | source=SOURCE_IMPORT, 129 | options={}, 130 | ) 131 | entry.add_to_hass(hass) 132 | 133 | # For YAML imports, the switch setup requires the unique_id to be in 134 | # hass.data[DOMAIN]["__yaml__"], otherwise it deletes the entry. 135 | # This simulates what async_step_import does. 136 | hass.data.setdefault(DOMAIN, {}).setdefault("__yaml__", set()).add(entry.unique_id) 137 | 138 | await hass.config_entries.async_setup(entry.entry_id) 139 | await hass.async_block_till_done() 140 | 141 | result = await hass.config_entries.options.async_init(entry.entry_id) 142 | 143 | # For YAML imports, the options flow shows an empty form (data_schema=None) 144 | # This is intentional - users should modify YAML, not UI 145 | assert result["type"] == FlowResultType.FORM 146 | assert result["step_id"] == "init" 147 | assert result.get("data_schema") is None 148 | 149 | 150 | async def test_menu_shown_when_entries_exist(hass): 151 | """Test that menu step is shown when existing entries exist.""" 152 | # Create an existing entry 153 | entry = MockConfigEntry( 154 | domain=DOMAIN, 155 | title="existing", 156 | data={CONF_NAME: "existing"}, 157 | options={"min_brightness": 10}, 158 | ) 159 | entry.add_to_hass(hass) 160 | 161 | # Start a new config flow - should show menu 162 | result = await hass.config_entries.flow.async_init( 163 | DOMAIN, 164 | context={"source": "user"}, 165 | ) 166 | 167 | assert result["type"] == FlowResultType.FORM 168 | assert result["step_id"] == "menu" 169 | 170 | 171 | async def test_menu_create_new_instance(hass): 172 | """Test creating a new instance through the menu.""" 173 | # Create an existing entry 174 | entry = MockConfigEntry( 175 | domain=DOMAIN, 176 | title="existing", 177 | data={CONF_NAME: "existing"}, 178 | options={"min_brightness": 10}, 179 | ) 180 | entry.add_to_hass(hass) 181 | 182 | # Start config flow - shows menu 183 | result = await hass.config_entries.flow.async_init( 184 | DOMAIN, 185 | context={"source": "user"}, 186 | ) 187 | assert result["step_id"] == "menu" 188 | 189 | # Choose to create new instance 190 | result = await hass.config_entries.flow.async_configure( 191 | result["flow_id"], 192 | user_input={"action": "new"}, 193 | ) 194 | 195 | # Should show name form 196 | assert result["type"] == FlowResultType.FORM 197 | assert result["step_id"] == "user" 198 | 199 | # Enter name 200 | result = await hass.config_entries.flow.async_configure( 201 | result["flow_id"], 202 | user_input={CONF_NAME: "new instance"}, 203 | ) 204 | 205 | assert result["type"] == FlowResultType.CREATE_ENTRY 206 | assert result["title"] == "new instance" 207 | # New instance should have no options (not duplicated) 208 | assert result["options"] == {} 209 | 210 | 211 | async def test_menu_duplicate_instance(hass): 212 | """Test duplicating an existing instance through the menu.""" 213 | # Create an existing entry with custom options 214 | source_options = {"min_brightness": 20, "max_brightness": 80} 215 | entry = MockConfigEntry( 216 | domain=DOMAIN, 217 | title="source", 218 | data={CONF_NAME: "source"}, 219 | options=source_options, 220 | ) 221 | entry.add_to_hass(hass) 222 | 223 | # Start config flow - shows menu 224 | result = await hass.config_entries.flow.async_init( 225 | DOMAIN, 226 | context={"source": "user"}, 227 | ) 228 | assert result["step_id"] == "menu" 229 | 230 | # Choose to duplicate existing entry 231 | result = await hass.config_entries.flow.async_configure( 232 | result["flow_id"], 233 | user_input={"action": entry.entry_id}, 234 | ) 235 | 236 | # Should show name form 237 | assert result["type"] == FlowResultType.FORM 238 | assert result["step_id"] == "user" 239 | 240 | # Enter name for duplicated instance 241 | result = await hass.config_entries.flow.async_configure( 242 | result["flow_id"], 243 | user_input={CONF_NAME: "duplicated"}, 244 | ) 245 | 246 | assert result["type"] == FlowResultType.CREATE_ENTRY 247 | assert result["title"] == "duplicated" 248 | # Duplicated instance should have copied options 249 | assert result["options"] == source_options 250 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is auto-generated by .github/update-services.py. 2 | apply: 3 | description: Applies the current Adaptive Lighting settings to lights. 4 | fields: 5 | entity_id: 6 | description: The `entity_id` of the switch with the settings to apply. 📝 7 | selector: 8 | entity: 9 | integration: adaptive_lighting 10 | domain: switch 11 | multiple: false 12 | lights: 13 | description: A light (or list of lights) to apply the settings to. 💡 14 | selector: 15 | entity: 16 | domain: light 17 | multiple: true 18 | transition: 19 | description: Duration of transition when lights change, in seconds. 🕑 20 | example: 10 21 | selector: 22 | text: null 23 | adapt_brightness: 24 | description: Whether to adapt the brightness of the light. 🌞 25 | example: true 26 | selector: 27 | boolean: null 28 | adapt_color: 29 | description: Whether to adapt the color on supporting lights. 🌈 30 | example: true 31 | selector: 32 | boolean: null 33 | prefer_rgb_color: 34 | description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 35 | example: false 36 | selector: 37 | boolean: null 38 | turn_on_lights: 39 | description: Whether to turn on lights that are currently off. 🔆 40 | example: false 41 | selector: 42 | boolean: null 43 | set_manual_control: 44 | description: Mark whether a light is 'manually controlled'. 45 | fields: 46 | entity_id: 47 | description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 48 | selector: 49 | entity: 50 | integration: adaptive_lighting 51 | domain: switch 52 | multiple: false 53 | lights: 54 | description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 55 | selector: 56 | entity: 57 | domain: light 58 | multiple: true 59 | manual_control: 60 | description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 61 | example: true 62 | default: true 63 | selector: 64 | boolean: null 65 | change_switch_settings: 66 | description: Change any settings you'd like in the switch. All options here are the same as in the config flow. 67 | fields: 68 | entity_id: 69 | description: Entity ID of the switch. 📝 70 | required: true 71 | selector: 72 | entity: 73 | domain: switch 74 | use_defaults: 75 | description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️' 76 | example: current 77 | required: false 78 | default: current 79 | selector: 80 | select: 81 | options: 82 | - current 83 | - configuration 84 | - factory 85 | include_config_in_attributes: 86 | description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 87 | required: false 88 | selector: 89 | boolean: null 90 | turn_on_lights: 91 | description: Whether to turn on lights that are currently off. 🔆 92 | example: false 93 | required: false 94 | selector: 95 | boolean: null 96 | initial_transition: 97 | description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ 98 | example: 1 99 | required: false 100 | selector: 101 | text: null 102 | sleep_transition: 103 | description: Duration of transition when "sleep mode" is toggled in seconds. 😴 104 | example: 1 105 | required: false 106 | selector: 107 | text: null 108 | max_brightness: 109 | description: Maximum brightness percentage. 💡 110 | required: false 111 | example: 100 112 | selector: 113 | text: null 114 | max_color_temp: 115 | description: Coldest color temperature in Kelvin. ❄️ 116 | required: false 117 | example: 5500 118 | selector: 119 | text: null 120 | min_brightness: 121 | description: Minimum brightness percentage. 💡 122 | required: false 123 | example: 1 124 | selector: 125 | text: null 126 | min_color_temp: 127 | description: Warmest color temperature in Kelvin. 🔥 128 | required: false 129 | example: 2000 130 | selector: 131 | text: null 132 | only_once: 133 | description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 134 | example: false 135 | required: false 136 | selector: 137 | boolean: null 138 | prefer_rgb_color: 139 | description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 140 | required: false 141 | example: false 142 | selector: 143 | boolean: null 144 | separate_turn_on_commands: 145 | description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 146 | required: false 147 | example: false 148 | selector: 149 | boolean: null 150 | send_split_delay: 151 | description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ 152 | required: false 153 | example: 0 154 | selector: 155 | boolean: null 156 | sleep_brightness: 157 | description: Brightness percentage of lights in sleep mode. 😴 158 | required: false 159 | example: 1 160 | selector: 161 | text: null 162 | sleep_rgb_or_color_temp: 163 | description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 164 | required: false 165 | example: color_temp 166 | selector: 167 | select: 168 | options: 169 | - rgb_color 170 | - color_temp 171 | sleep_rgb_color: 172 | description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 173 | required: false 174 | selector: 175 | color_rgb: null 176 | sleep_color_temp: 177 | description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 178 | required: false 179 | example: 1000 180 | selector: 181 | text: null 182 | sunrise_offset: 183 | description: Adjust sunrise time with a positive or negative offset in seconds. ⏰ 184 | required: false 185 | example: 0 186 | selector: 187 | number: 188 | min: 0 189 | max: 86300 190 | sunrise_time: 191 | description: Set a fixed time (HH:MM:SS) for sunrise. 🌅 192 | required: false 193 | example: '' 194 | selector: 195 | time: null 196 | sunset_offset: 197 | description: Adjust sunset time with a positive or negative offset in seconds. ⏰ 198 | required: false 199 | example: '' 200 | selector: 201 | number: 202 | min: 0 203 | max: 86300 204 | sunset_time: 205 | description: Set a fixed time (HH:MM:SS) for sunset. 🌇 206 | example: '' 207 | required: false 208 | selector: 209 | time: null 210 | max_sunrise_time: 211 | description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 212 | example: '' 213 | required: false 214 | selector: 215 | time: null 216 | min_sunset_time: 217 | description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇 218 | example: '' 219 | required: false 220 | selector: 221 | time: null 222 | take_over_control: 223 | description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 224 | required: false 225 | example: true 226 | selector: 227 | boolean: null 228 | detect_non_ha_changes: 229 | description: 'Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an ''on'' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.' 230 | required: false 231 | example: false 232 | selector: 233 | boolean: null 234 | transition: 235 | description: Duration of transition when lights change, in seconds. 🕑 236 | required: false 237 | example: 45 238 | selector: 239 | text: null 240 | adapt_delay: 241 | description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ 242 | required: false 243 | example: 0 244 | selector: 245 | text: null 246 | autoreset_control_seconds: 247 | description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ 248 | required: false 249 | example: 0 250 | selector: 251 | text: null 252 | -------------------------------------------------------------------------------- /tests/test_adaptation_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for Adaptive Lighting utils.""" 2 | 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from homeassistant.components.adaptive_lighting.adaptation_utils import ( 7 | ServiceData, 8 | _create_service_call_data_iterator, 9 | _has_relevant_service_data_attributes, 10 | _remove_redundant_attributes, 11 | _split_service_call_data, 12 | prepare_adaptation_data, 13 | ) 14 | from homeassistant.components.light import ( 15 | ATTR_BRIGHTNESS, 16 | ATTR_COLOR_TEMP_KELVIN, 17 | ATTR_TRANSITION, 18 | ) 19 | from homeassistant.const import ATTR_ENTITY_ID, STATE_ON 20 | from homeassistant.core import Context, State 21 | 22 | 23 | @pytest.mark.parametrize( 24 | ("input_data", "expected_data_list"), 25 | [ 26 | ( 27 | {"foo": 1}, 28 | [], 29 | ), 30 | ( 31 | {ATTR_BRIGHTNESS: 10}, 32 | [{ATTR_BRIGHTNESS: 10}], 33 | ), 34 | ( 35 | {ATTR_COLOR_TEMP_KELVIN: 3500}, 36 | [{ATTR_COLOR_TEMP_KELVIN: 3500}], 37 | ), 38 | ( 39 | {ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}, 40 | [{ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}], 41 | ), 42 | ( 43 | {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500}, 44 | [{ATTR_BRIGHTNESS: 10}, {ATTR_COLOR_TEMP_KELVIN: 3500}], 45 | ), 46 | ( 47 | {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 2}, 48 | [ 49 | {ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 1}, 50 | {ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 1}, 51 | ], 52 | ), 53 | ( 54 | {ATTR_TRANSITION: 1}, 55 | [], 56 | ), 57 | ], 58 | ids=[ 59 | "remove irrelevant attributes", 60 | "brightness only yields one service call", 61 | "color only yields one service call", 62 | "include entity ID", 63 | "brightness and color are split into two with brightness first", 64 | "transition time is distributed among service calls", 65 | "ignore transition time without service calls", 66 | ], 67 | ) 68 | async def test_split_service_call_data(input_data, expected_data_list): 69 | """Test splitting of service call data.""" 70 | assert _split_service_call_data(input_data) == expected_data_list 71 | 72 | 73 | @pytest.mark.parametrize( 74 | ("service_data", "state", "service_data_expected"), 75 | [ 76 | ( 77 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 78 | State("light.test", STATE_ON), 79 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 80 | ), 81 | ( 82 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 83 | State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 10}), 84 | {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, 85 | ), 86 | ( 87 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 88 | State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 11}), 89 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 90 | ), 91 | ], 92 | ids=[ 93 | "pass all attributes on empty state", 94 | "remove attributes whose values equal the state", 95 | "keep attributes whose values differ from the state", 96 | ], 97 | ) 98 | async def test_remove_redundant_attributes( 99 | service_data: ServiceData, 100 | state: State | None, 101 | service_data_expected: ServiceData, 102 | ): 103 | """Test filtering of service data.""" 104 | assert _remove_redundant_attributes(service_data, state) == service_data_expected 105 | 106 | 107 | @pytest.mark.parametrize( 108 | ("service_data", "expected_relevant"), 109 | [ 110 | ( 111 | {ATTR_ENTITY_ID: "light.test"}, 112 | False, 113 | ), 114 | ( 115 | {ATTR_TRANSITION: 2}, 116 | False, 117 | ), 118 | ( 119 | {ATTR_BRIGHTNESS: 10}, 120 | True, 121 | ), 122 | ( 123 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, 124 | True, 125 | ), 126 | ], 127 | ) 128 | async def test_has_relevant_service_data_attributes( 129 | service_data: ServiceData, 130 | expected_relevant: bool, 131 | ): 132 | """Test the determination of relevancy of service data.""" 133 | assert _has_relevant_service_data_attributes(service_data) == expected_relevant 134 | 135 | 136 | @pytest.mark.parametrize( 137 | ("service_datas", "filter_by_state", "service_datas_expected"), 138 | [ 139 | ( 140 | [{ATTR_ENTITY_ID: "light.test"}], 141 | False, 142 | [{ATTR_ENTITY_ID: "light.test"}], 143 | ), 144 | ( 145 | [{ATTR_ENTITY_ID: "light.test"}, {ATTR_ENTITY_ID: "light.test2"}], 146 | False, 147 | [{ATTR_ENTITY_ID: "light.test"}, {ATTR_ENTITY_ID: "light.test2"}], 148 | ), 149 | ( 150 | [{ATTR_ENTITY_ID: "light.test"}], 151 | True, 152 | [], 153 | ), 154 | ( 155 | [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}], 156 | True, 157 | [], 158 | ), 159 | ( 160 | [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], 161 | True, 162 | [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], 163 | ), 164 | ( 165 | [ 166 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, 167 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, 168 | ], 169 | True, 170 | [ 171 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, 172 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, 173 | ], 174 | ), 175 | ( 176 | [ 177 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}, 178 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, 179 | ], 180 | True, 181 | [ 182 | {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, 183 | ], 184 | ), 185 | ], 186 | ids=[ 187 | "single item passed through without filtering", 188 | "two items passed through without filtering", 189 | "filter removes item without relevant attributes", 190 | "filter removes item with relevant attribute that equals the state", 191 | "filter keeps item with relevant attribute that is different from state", 192 | "filter keeps two items with relevant attributes that are different from state", 193 | "filter removes item that equals state and keeps items that differs from state", 194 | ], 195 | ) 196 | async def test_create_service_call_data_iterator( 197 | service_datas: list[ServiceData], 198 | filter_by_state: bool, 199 | service_datas_expected: list[ServiceData], 200 | hass_states_mock, 201 | ): 202 | """Test the generator function for correct enumeration and filtering.""" 203 | generated_service_datas = [ 204 | data 205 | async for data in _create_service_call_data_iterator( 206 | hass_states_mock, 207 | service_datas, 208 | filter_by_state, 209 | ) 210 | ] 211 | 212 | assert generated_service_datas == service_datas_expected 213 | assert ( 214 | hass_states_mock.states.get.call_count == 0 215 | if not filter_by_state 216 | else len(service_datas) 217 | ) 218 | 219 | 220 | @pytest.mark.parametrize( 221 | ( 222 | "service_data", 223 | "split", 224 | "filter_by_state", 225 | "service_datas_expected", 226 | "sleep_time_expected", 227 | ), 228 | [ 229 | ( 230 | { 231 | ATTR_ENTITY_ID: "light.test", 232 | ATTR_BRIGHTNESS: 10, 233 | ATTR_COLOR_TEMP_KELVIN: 4000, 234 | }, 235 | False, 236 | False, 237 | [ 238 | { 239 | ATTR_ENTITY_ID: "light.test", 240 | ATTR_BRIGHTNESS: 10, 241 | ATTR_COLOR_TEMP_KELVIN: 4000, 242 | }, 243 | ], 244 | 1.2, 245 | ), 246 | ( 247 | { 248 | ATTR_ENTITY_ID: "light.test", 249 | ATTR_BRIGHTNESS: 10, 250 | ATTR_COLOR_TEMP_KELVIN: 4000, 251 | }, 252 | True, 253 | False, 254 | [ 255 | { 256 | ATTR_ENTITY_ID: "light.test", 257 | ATTR_BRIGHTNESS: 10, 258 | }, 259 | { 260 | ATTR_ENTITY_ID: "light.test", 261 | ATTR_COLOR_TEMP_KELVIN: 4000, 262 | }, 263 | ], 264 | 0.7, 265 | ), 266 | ( 267 | { 268 | ATTR_ENTITY_ID: "light.test", 269 | ATTR_BRIGHTNESS: 10, 270 | ATTR_COLOR_TEMP_KELVIN: 4000, 271 | }, 272 | False, 273 | True, 274 | [ 275 | { 276 | ATTR_ENTITY_ID: "light.test", 277 | ATTR_COLOR_TEMP_KELVIN: 4000, 278 | }, 279 | ], 280 | 1.2, 281 | ), 282 | ( 283 | { 284 | ATTR_ENTITY_ID: "light.test", 285 | ATTR_BRIGHTNESS: 10, 286 | ATTR_COLOR_TEMP_KELVIN: 4000, 287 | }, 288 | True, 289 | True, 290 | [ 291 | { 292 | ATTR_ENTITY_ID: "light.test", 293 | ATTR_COLOR_TEMP_KELVIN: 4000, 294 | }, 295 | ], 296 | 0.7, 297 | ), 298 | ], 299 | ids=[ 300 | "service data passed through", 301 | "service data split", 302 | "service data filtered", 303 | "service data split and filtered", 304 | ], 305 | ) 306 | async def test_prepare_adaptation_data( 307 | hass_states_mock, 308 | service_data, 309 | split, 310 | filter_by_state, 311 | service_datas_expected, 312 | sleep_time_expected, 313 | ): 314 | """Test creation of correct service data objects.""" 315 | data = prepare_adaptation_data( 316 | hass_states_mock, 317 | "test.entity", 318 | Context(id="test-id"), 319 | 1, 320 | 0.2, 321 | service_data, 322 | split, 323 | filter_by_state, 324 | force=False, 325 | ) 326 | 327 | generated_service_datas = [item async for item in data.service_call_datas] 328 | 329 | assert data.entity_id == "test.entity" 330 | assert data.context.id == "test-id" 331 | assert data.sleep_time == sleep_time_expected 332 | assert generated_service_datas == service_datas_expected 333 | 334 | 335 | @pytest.fixture(name="hass_states_mock") 336 | def fixture_hass_states_mock(): 337 | """Mocks a HA state machine which returns a mock state.""" 338 | hass = Mock() 339 | hass.states.get.return_value = Mock(attributes={ATTR_BRIGHTNESS: 10}) 340 | return hass 341 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "自适应照明", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "为自适应照明实例选择一个名称", 7 | "description": "每个实例可以包含多个灯光!", 8 | "data": { 9 | "name": "名称" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "此设备已配置" 15 | } 16 | }, 17 | "options": { 18 | "step": { 19 | "init": { 20 | "title": "自适应照明选项", 21 | "description": "配置自适应照明组件。选项名称与YAML设置对齐。如果在YAML中定义了此条目,则此处不会显示任何选项。有关演示参数影响的交互式图表,请访问[此Web应用程序](https://basnijholt.github.io/adaptive-lighting)。有关更多详细信息,请参阅[官方文档](https://github.com/basnijholt/adaptive-lighting#readme)。", 22 | "data": { 23 | "lights": "lights:要控制的灯光实体ID列表(可以为空)。🌟", 24 | "interval": "频率(interval)", 25 | "transition": "过渡(transition)", 26 | "initial_transition": "初始过渡(initial_transition)", 27 | "min_brightness": "min_brightness:最小亮度百分比。💡", 28 | "max_brightness": "max_brightness:最大亮度百分比。💡", 29 | "min_color_temp": "min_color_temp:最暖的色温,以开尔文为单位。🔥", 30 | "max_color_temp": "max_color_temp:最冷的色温,以开尔文为单位。❄️", 31 | "prefer_rgb_color": "prefer_rgb_color:在可能时是否优先使用RGB颜色调整而不是灯光色温。🌈", 32 | "sleep_brightness": "睡眠模式亮度(sleep_brightness)", 33 | "sleep_rgb_or_color_temp": "睡眠模式RGB或色温(sleep_rgb_or_color_temp)", 34 | "sleep_color_temp": "睡眠模式中的色温(sleep_color_temp)", 35 | "sleep_rgb_color": "睡眠模式中的RGB颜色(sleep_rgb_color)", 36 | "sleep_transition": "睡眠模式过渡时间(sleep_transition)", 37 | "transition_until_sleep": "transition_until_sleep:启用时,自适应照明将将睡眠设置视为最小值,在日落后过渡到这些值。🌙", 38 | "sunrise_time": "日出时间(sunrise_time)", 39 | "min_sunrise_time": "最早日出时间(min_sunrise_time)", 40 | "max_sunrise_time": "最晚日出时间(max_sunrise_time)", 41 | "sunrise_offset": "日出时间偏移(sunrise_offset)", 42 | "sunset_time": "日落时间(sunset_time)", 43 | "min_sunset_time": "最早日落时间(min_sunset_time)", 44 | "max_sunset_time": "最晚日落时间(max_sunset_time)", 45 | "sunset_offset": "日落时间偏移(sunset_offset)", 46 | "brightness_mode": "亮度模式(brightness_mode)", 47 | "brightness_mode_time_dark": "变暗时间(brightness_mode_time_dark)", 48 | "brightness_mode_time_light": "变亮时间(brightness_mode_time_light)", 49 | "take_over_control": "take_over_control: 如果在灯光处于开启并处于适应照明的状态时,另一个来源调用`light.turn_on`,则禁用自适应照明。请注意,这会在每个`interval`调用`homeassistant.update_entity`!🔒", 50 | "detect_non_ha_changes": "detect_non_ha_changes: 检测非`light.turn_on`的状态更改,并停止自适应照明。需要启用`take_over_control`。🕵️ 注意:⚠️ 一些灯光可能错误地显示为“开启”状态,这可能会导致灯光意外打开。如果遇到此类问题,请禁用此功能。", 51 | "autoreset_control_seconds": "自动重置时间(autoreset_control_seconds)", 52 | "only_once": "only_once:仅在打开时调整灯光(`true`)或始终调整灯光(`false`)。🔄", 53 | "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on:当首次打开灯光时。如果设置为`true`,仅在没有指定颜色或亮度的情况下,AL才进行适应。❌🌈 例如,这可以防止在激活场景时进行适应。如果为`false`,则不考虑初始`service_data`中是否存在颜色或亮度,AL都会适应。需要启用`take_over_control`。🕵️", 54 | "separate_turn_on_commands": "separate_turn_on_commands:为某些灯光类型需要使用单独的`light.turn_on`调用来设置颜色和亮度。🔀", 55 | "send_split_delay": "指令发送间隔延迟(send_split_delay)", 56 | "adapt_delay": "自适应照明延迟(adapt_delay)", 57 | "skip_redundant_commands": "skip_redundant_commands:跳过目标状态已经等于灯光已知状态的自适应命令。在某些情况下,可以减少网络流量并提高适应响应性。📉如果物理灯光状态与HA的记录状态不同步,请禁用此功能。", 58 | "intercept": "intercept:拦截并适应`light.turn_on`调用,以实现即时的颜色和亮度适应。🏎️ 对于不支持使用颜色和亮度进行`light.turn_on`的灯光,禁用此功能。", 59 | "multi_light_intercept": "multi_light_intercept:拦截和适应针对多个灯光的`light.turn_on`调用。➗⚠️ 这可能会将单个`light.turn_on`调用拆分为多个调用,例如当灯光位于不同的开关中时。需要启用`intercept`。", 60 | "include_config_in_attributes": "include_config_in_attributes:在Home Assistant中将所有选项显示为开关的属性时,设置为`true`。📝" 61 | }, 62 | "data_description": { 63 | "interval": "调整灯光的频率,以秒为单位。🔄", 64 | "transition": "灯光变化时的过渡持续时间,以秒为单位。🕑", 65 | "initial_transition": "灯光从“关闭”到“开启”时的第一个过渡持续时间,以秒为单位。⏲️", 66 | "sleep_brightness": "睡眠模式中的亮度百分比。😴", 67 | "sleep_rgb_or_color_temp": "在睡眠模式中使用“rgb_color”或“color_temp”。🌙", 68 | "sleep_color_temp": "睡眠模式中的色温(当`sleep_rgb_or_color_temp`为`color_temp`时使用),以开尔文为单位。😴", 69 | "sleep_rgb_color": "睡眠模式中的RGB颜色(当`sleep_rgb_or_color_temp`为“rgb_color”时使用)。🌈", 70 | "sleep_transition": "切换“睡眠模式”时的过渡持续时间,以秒为单位。😴", 71 | "sunrise_time": "设置固定的日出时间(HH:MM:SS)。🌅", 72 | "min_sunrise_time": "设置最早的虚拟日出时间(HH:MM:SS),允许更晚的日出。🌅", 73 | "max_sunrise_time": "设置最晚的虚拟日出时间(HH:MM:SS),允许更早的日出。🌅", 74 | "sunrise_offset": "以秒为单位的正负偏移调整日出时间。⏰", 75 | "sunset_time": "设置固定的日落时间(HH:MM:SS)。🌇", 76 | "min_sunset_time": "设置最早的虚拟日落时间(HH:MM:SS),允许更晚的日落。🌇", 77 | "max_sunset_time": "设置最晚的虚拟日落时间(HH:MM:SS),允许更早的日落。🌇", 78 | "sunset_offset": "以秒为单位的正负偏移调整日落时间。⏰", 79 | "brightness_mode": "要使用的亮度模式。可能的值为`default`、`linear`和`tanh`(使用`brightness_mode_time_dark`和`brightness_mode_time_light`)。📈", 80 | "brightness_mode_time_dark": "(如果`brightness_mode='default'`将被忽略)日出/日落之前/之后亮度逐渐增加/减少的持续时间,以秒为单位。📈📉", 81 | "brightness_mode_time_light": "(如果`brightness_mode='default'`将被忽略)日出/日落之后/之前亮度逐渐增加/减少的持续时间,以秒为单位。📈📉。", 82 | "autoreset_control_seconds": "在若干秒后自动重置手动控制。设置为0以禁用。⏲️", 83 | "send_split_delay": "对于不支持同时设置亮度和颜色的灯光,`separate_turn_on_commands`之间的延迟时间(毫秒)。⏲️", 84 | "adapt_delay": "灯光打开和自适应照明应用更改之间的等待时间(秒)。可能有助于避免闪烁。⏲️" 85 | } 86 | } 87 | }, 88 | "error": { 89 | "option_error": "无效的选项", 90 | "entity_missing": "一个或多个选择的灯光实体在Home Assistant中不存在" 91 | } 92 | }, 93 | "services": { 94 | "apply": { 95 | "name": "应用", 96 | "description": "将当前自适应照明设置应用于灯光。", 97 | "fields": { 98 | "entity_id": { 99 | "description": "具有要应用设置的开关的`entity_id`。📝", 100 | "name": "entity_id" 101 | }, 102 | "lights": { 103 | "description": "要应用设置的灯光(或灯光列表)。💡", 104 | "name": "lights" 105 | }, 106 | "transition": { 107 | "description": "灯光变化时的过渡持续时间,以秒为单位。🕑", 108 | "name": "transition" 109 | }, 110 | "adapt_brightness": { 111 | "description": "是否调整灯光的亮度。🌞", 112 | "name": "adapt_brightness" 113 | }, 114 | "adapt_color": { 115 | "description": "是否在支持的灯光上调整颜色。🌈", 116 | "name": "adapt_color" 117 | }, 118 | "prefer_rgb_color": { 119 | "description": "在可能时是否优先使用RGB颜色调整而不是灯光色温。🌈", 120 | "name": "prefer_rgb_color" 121 | }, 122 | "turn_on_lights": { 123 | "description": "是否打开当前关闭的灯光。🔆", 124 | "name": "turn_on_lights" 125 | } 126 | } 127 | }, 128 | "set_manual_control": { 129 | "name": "设置手动控制", 130 | "description": "标记灯光是否为'手动控制'。", 131 | "fields": { 132 | "entity_id": { 133 | "description": "要在其中(取消)标记灯光为“手动控制”的开关的`entity_id`。📝", 134 | "name": "entity_id" 135 | }, 136 | "lights": { 137 | "description": "如果未指定,则为灯光的entity_id(s),如果未指定,则选择开关中的所有灯光。💡", 138 | "name": "lights" 139 | }, 140 | "manual_control": { 141 | "description": "是否将灯光从“手动控制”列表中添加(“true”)或删除(“false”)。🔒", 142 | "name": "manual_control" 143 | } 144 | } 145 | }, 146 | "change_switch_settings": { 147 | "name": "更改开关设置", 148 | "description": "在开关中更改您想要的任何设置。此处的所有选项与配置流中的选项相同。", 149 | "fields": { 150 | "entity_id": { 151 | "description": "开关的实体ID。📝", 152 | "name": "entity_id" 153 | }, 154 | "use_defaults": { 155 | "description": "设置未在此服务调用中指定的默认值。选项:“current”(默认值,保留当前值)、“factory”(重置为文档默认值)或“configuration”(恢复到开关配置默认值)。⚙️", 156 | "name": "use_defaults" 157 | }, 158 | "include_config_in_attributes": { 159 | "description": "在Home Assistant中将所有选项显示为开关的属性时,设置为`true`。📝", 160 | "name": "include_config_in_attributes" 161 | }, 162 | "turn_on_lights": { 163 | "description": "是否打开当前关闭的灯光。🔆", 164 | "name": "turn_on_lights" 165 | }, 166 | "initial_transition": { 167 | "description": "灯光从“关闭”到“开启”时的第一个过渡持续时间,以秒为单位。⏲️", 168 | "name": "initial_transition" 169 | }, 170 | "sleep_transition": { 171 | "description": "切换“睡眠模式”时的过渡持续时间,以秒为单位。😴", 172 | "name": "sleep_transition" 173 | }, 174 | "max_brightness": { 175 | "description": "最大亮度百分比。💡", 176 | "name": "max_brightness" 177 | }, 178 | "max_color_temp": { 179 | "description": "最低的色温,以开尔文为单位。❄️", 180 | "name": "max_color_temp" 181 | }, 182 | "min_brightness": { 183 | "description": "最小亮度百分比。💡", 184 | "name": "min_brightness" 185 | }, 186 | "min_color_temp": { 187 | "description": "最高的色温,以开尔文为单位。🔥", 188 | "name": "min_color_temp" 189 | }, 190 | "only_once": { 191 | "description": "仅在打开时调整灯光(`true`)或始终调整灯光(`false`)。🔄", 192 | "name": "only_once" 193 | }, 194 | "prefer_rgb_color": { 195 | "description": "在可能时是否优先使用RGB颜色调整而不是灯光色温。🌈", 196 | "name": "prefer_rgb_color" 197 | }, 198 | "separate_turn_on_commands": { 199 | "description": "为某些灯光类型需要使用单独的`light.turn_on`调用来设置颜色和亮度。🔀", 200 | "name": "separate_turn_on_commands" 201 | }, 202 | "send_split_delay": { 203 | "description": "对于不支持同时设置亮度和颜色的灯光,`separate_turn_on_commands`之间的延迟时间(毫秒)。⏲️", 204 | "name": "send_split_delay" 205 | }, 206 | "sleep_brightness": { 207 | "description": "睡眠模式中的亮度百分比。😴", 208 | "name": "sleep_brightness" 209 | }, 210 | "sleep_rgb_or_color_temp": { 211 | "description": "在睡眠模式中使用“rgb_color”或“color_temp”。🌙", 212 | "name": "sleep_rgb_or_color_temp" 213 | }, 214 | "sleep_rgb_color": { 215 | "description": "睡眠模式中的RGB颜色(当`sleep_rgb_or_color_temp`为“rgb_color”时使用)。🌈", 216 | "name": "sleep_rgb_color" 217 | }, 218 | "sleep_color_temp": { 219 | "description": "睡眠模式中的色温(当`sleep_rgb_or_color_temp`为`color_temp`时使用),以开尔文为单位。😴", 220 | "name": "sleep_color_temp" 221 | }, 222 | "sunrise_offset": { 223 | "description": "以秒为单位的正负偏移调整日出时间。⏰", 224 | "name": "sunrise_offset" 225 | }, 226 | "sunrise_time": { 227 | "description": "设置固定的日出时间(HH:MM:SS)。🌅", 228 | "name": "sunrise_time" 229 | }, 230 | "sunset_offset": { 231 | "description": "以正负偏移秒调整日落时间。⏰", 232 | "name": "sunset_offset" 233 | }, 234 | "sunset_time": { 235 | "description": "设置固定的日落时间(HH:MM:SS)。🌇", 236 | "name": "sunset_time" 237 | }, 238 | "max_sunrise_time": { 239 | "description": "设置最晚的虚拟日出时间(HH:MM:SS),允许更早的日出。🌅", 240 | "name": "max_sunrise_time" 241 | }, 242 | "min_sunset_time": { 243 | "description": "设置最早的虚拟日落时间(HH:MM:SS),允许更晚的日落。🌇", 244 | "name": "min_sunset_time" 245 | }, 246 | "take_over_control": { 247 | "description": "如果其他来源在灯光处于打开和正在适应状态时调用`light.turn_on`,则禁用自适应照明。请注意,这会每个`interval`调用`homeassistant.update_entity`!🔒", 248 | "name": "take_over_control" 249 | }, 250 | "detect_non_ha_changes": { 251 | "description": "检测并停止对非`light.turn_on`状态更改的适应。需要启用`take_over_control`。🕵️ 注意:⚠️ 一些灯光可能错误地显示为“开启”状态,这可能导致灯光意外打开。如果遇到此类问题,请禁用此功能。", 252 | "name": "detect_non_ha_changes" 253 | }, 254 | "transition": { 255 | "description": "灯光变化时的过渡持续时间,以秒为单位。🕑", 256 | "name": "transition" 257 | }, 258 | "adapt_delay": { 259 | "description": "灯光打开和自适应照明应用更改之间的等待时间(秒)。可能有助于避免闪烁。⏲️", 260 | "name": "adapt_delay" 261 | }, 262 | "autoreset_control_seconds": { 263 | "description": "在若干秒后自动重置手动控制。设置为0以禁用。⏲️", 264 | "name": "autoreset_control_seconds" 265 | } 266 | } 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "적응형 조명", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "적응형 조명 인스턴스 이름 선택", 7 | "description": "각 인스턴스는 여러 조명을 포함할 수 있습니다!", 8 | "data": { 9 | "name": "이름" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "이 장치는 이미 구성되었습니다" 15 | } 16 | }, 17 | "options": { 18 | "step": { 19 | "init": { 20 | "title": "적응형 조명 옵션", 21 | "description": "적응형 조명 구성요소를 구성합니다. 옵션 이름은 YAML 설정과 일치합니다. 이 항목을 YAML에서 정의한 경우 여기에 옵션이 표시되지 않습니다. 매개변수 효과를 시연하는 인터랙티브 그래프는 [이 웹 앱](https://basnijholt.github.io/adaptive-lighting)에서 확인할 수 있습니다. 자세한 내용은 [공식 문서](https://github.com/basnijholt/adaptive-lighting#readme)를 참조하세요.", 22 | "data": { 23 | "lights": "조명: 제어될 조명 entity_ids의 목록 (비어 있을 수 있음). 🌟", 24 | "interval": "간격", 25 | "transition": "전환", 26 | "initial_transition": "초기 전환", 27 | "min_brightness": "최소 밝기: 밝기 최소 퍼센트. 💡", 28 | "max_brightness": "최대 밝기: 밝기 최대 퍼센트. 💡", 29 | "min_color_temp": "최소 색온도: 켈빈으로 표시된 가장 따뜻한 색온도. 🔥", 30 | "max_color_temp": "최대 색온도: 켈빈으로 표시된 가장 차가운 색온도. ❄️", 31 | "prefer_rgb_color": "RGB 색상 선호: 가능할 경우 색온도 조정보다 RGB 색상 조정을 선호하는지 여부. 🌈", 32 | "sleep_brightness": "수면 밝기", 33 | "sleep_rgb_or_color_temp": "수면 rgb_or_color_temp", 34 | "sleep_color_temp": "수면 색온도", 35 | "sleep_rgb_color": "수면 RGB 색상", 36 | "sleep_transition": "수면 전환", 37 | "transition_until_sleep": "수면까지 전환: 활성화되면, 적응형 조명은 수면 설정을 최소값으로 취급하고 일몰 후 이 값으로 전환합니다. 🌙", 38 | "sunrise_time": "일출 시간", 39 | "min_sunrise_time": "최소 일출 시간", 40 | "max_sunrise_time": "최대 일출 시간", 41 | "sunrise_offset": "일출 오프셋", 42 | "sunset_time": "일몰 시간", 43 | "min_sunset_time": "최소 일몰 시간", 44 | "max_sunset_time": "최대 일몰 시간", 45 | "sunset_offset": "일몰 오프셋", 46 | "brightness_mode": "밝기 모드", 47 | "brightness_mode_time_dark": "어두울 때 밝기 모드 시간", 48 | "brightness_mode_time_light": "밝을 때 밝기 모드 시간", 49 | "take_over_control": "제어 인계: 다른 소스가 조명이 켜져 있고 조정 중일 때 `light.turn_on`을 호출하면 적응형 조명을 비활성화합니다. 이는 매 `간격`마다 `homeassistant.update_entity`를 호출합니다! 🔒", 50 | "detect_non_ha_changes": "비HA 변경 감지: `light.turn_on`이 아닌 상태 변경을 감지하고 조정을 중단합니다. `take_over_control`이 활성화되어 있어야 합니다. 🕵️ 주의: ⚠️ 일부 조명은 잘못된 '켜짐' 상태를 나타낼 수 있으며, 이로 인해 조명이 예상치 못하게 켜질 수 있습니다. 이러한 문제가 발생하면 이 기능을 비활성화하세요.", 51 | "autoreset_control_seconds": "자동 제어 리셋 초", 52 | "only_once": "한 번만: 조명을 켤 때만 조정 (`true`) 또는 계속해서 조정 (`false`). 🔄", 53 | "adapt_only_on_bare_turn_on": "초기 켜짐 시 조정만: 조명을 처음 켤 때. `true`로 설정하면 `light.turn_on`이 색상이나 밝기를 지정하지 않고 호출될 때만 AL이 조정합니다. ❌🌈 예를 들어, 장면을 활성화할 때 조정을 방지합니다. `false`로 설정하면, AL은 초기 `service_data`에 색상이나 밝기의 존재 여부와 관계없이 조정합니다. `take_over_control`이 활성화되어 있어야 합니다. 🕵️", 54 | "separate_turn_on_commands": "분리된 켜기 명령 사용: 일부 조명 유형에 필요한 색상과 밝기에 대해 별도의 `light.turn_on` 호출을 사용합니다. 🔀", 55 | "send_split_delay": "분할 전송 지연", 56 | "adapt_delay": "조정 지연", 57 | "skip_redundant_commands": "중복 명령 건너뛰기: 목표 상태가 이미 조명의 알려진 상태와 동일한 조정 명령을 보내지 않습니다. 네트워크 트래픽을 최소화하고 일부 상황에서 조정 반응성을 향상시킵니다. 📉 물리적 조명 상태가 HA의 기록된 상태와 동기화되지 않는 경우 비활성화하세요.", 58 | "intercept": "가로채기: 색상과 밝기의 즉각적인 조정을 가능하게 하기 위해 `light.turn_on` 호출을 가로챕니다. 🏎️ 색상과 밝기를 지원하지 않는 조명에 대해 비활성화합니다.", 59 | "multi_light_intercept": "다중 조명 가로채기: 여러 조명을 대상으로 하는 `light.turn_on` 호출을 가로채고 조정합니다. ➗⚠️ 이는 단일 `light.turn_on` 호출을 여러 호출로 분할할 수 있음을 의미합니다. 예를 들어, 조명이 다른 스위치에 있을 때. `intercept`가 활성화되어 있어야 합니다.", 60 | "include_config_in_attributes": "속성에 구성 포함: `true`로 설정하면 Home Assistant에서 스위치의 모든 옵션을 속성으로 표시합니다. 📝" 61 | }, 62 | "data_description": { 63 | "interval": "조명을 조정하는 빈도, 초 단위. 🔄", 64 | "transition": "조명이 변경될 때 전환 기간, 초 단위. 🕑", 65 | "initial_transition": "조명이 `off`에서 `on`으로 바뀔 때 첫 번째 전환의 지속 시간, 초 단위. ⏲️", 66 | "sleep_brightness": "수면 모드에서 조명의 밝기 퍼센트. 😴", 67 | "sleep_rgb_or_color_temp": "수면 모드에서 `\"rgb_color\"` 또는 `\"color_temp\"` 사용. 🌙", 68 | "sleep_color_temp": "수면 모드에서 색온도 (sleep_rgb_or_color_temp가 `color_temp`일 때 사용) 켈빈 단위. 😴", 69 | "sleep_rgb_color": "수면 모드에서 RGB 색상 (sleep_rgb_or_color_temp가 \"rgb_color\"일 때 사용). 🌈", 70 | "sleep_transition": "\"수면 모드\"가 전환될 때 전환 기간, 초 단위. 😴", 71 | "sunrise_time": "일출 시간을 고정된 시간 (HH:MM:SS)으로 설정. 🌅", 72 | "min_sunrise_time": "가장 이른 가상 일출 시간 (HH:MM:SS)을 설정하여 더 늦은 일출을 허용. 🌅", 73 | "max_sunrise_time": "가장 늦은 가상 일출 시간 (HH:MM:SS)을 설정하여 더 일찍 일출을 허용. 🌅", 74 | "sunrise_offset": "양수 또는 음수 오프셋(초)으로 일출 시간을 조정. ⏰", 75 | "sunset_time": "일몰 시간을 고정된 시간 (HH:MM:SS)으로 설정. 🌇", 76 | "min_sunset_time": "가장 이른 가상 일몰 시간 (HH:MM:SS)을 설정하여 더 늦은 일몰을 허용. 🌇", 77 | "max_sunset_time": "가장 늦은 가상 일몰 시간 (HH:MM:SS)을 설정하여 더 일찍 일몰을 허용. 🌇", 78 | "sunset_offset": "양수 또는 음수 오프셋(초)으로 일몰 시간을 조정. ⏰", 79 | "brightness_mode": "사용할 밝기 모드. 가능한 값은 `default`, `linear`, `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈", 80 | "brightness_mode_time_dark": "(`brightness_mode='default'`인 경우 무시됨) 일출/일몰 전/후에 밝기를 높이거나 낮추는 데 걸리는 시간, 초 단위. 📈📉", 81 | "brightness_mode_time_light": "(`brightness_mode='default'`인 경우 무시됨) 일출/일몰 후/전에 밝기를 높이거나 낮추는 데 걸리는 시간, 초 단위. 📈📉.", 82 | "autoreset_control_seconds": "특정 초 후에 수동 제어를 자동으로 재설정. 0으로 설정하면 비활성화됩니다. ⏲️", 83 | "send_split_delay": "`separate_turn_on_commands`에 대한 호출 사이의 지연 시간(밀리초)으로, 밝기와 색상을 동시에 설정하지 않는 조명에 대한 지연. ⏲️", 84 | "adapt_delay": "조명을 켠 후 적응형 조명이 변경 사항을 적용하기까지의 대기 시간(초). 깜박임을 피하는 데 도움이 될 수 있습니다. ⏲️" 85 | } 86 | } 87 | }, 88 | "error": { 89 | "option_error": "잘못된 옵션", 90 | "entity_missing": "선택한 하나 이상의 조명 엔티티가 Home Assistant에서 누락됨" 91 | } 92 | }, 93 | "services": { 94 | "apply": { 95 | "name": "적용", 96 | "description": "현재 적응형 조명 설정을 조명에 적용합니다.", 97 | "fields": { 98 | "entity_id": { 99 | "description": "설정을 적용할 스위치의 `entity_id`. 📝", 100 | "name": "entity_id" 101 | }, 102 | "lights": { 103 | "description": "설정을 적용할 조명(또는 조명 목록). 💡", 104 | "name": "lights" 105 | }, 106 | "transition": { 107 | "description": "조명 변경 시 전환 기간, 초 단위. 🕑", 108 | "name": "transition" 109 | }, 110 | "adapt_brightness": { 111 | "description": "조명의 밝기를 조정할지 여부. 🌞", 112 | "name": "adapt_brightness" 113 | }, 114 | "adapt_color": { 115 | "description": "지원하는 조명의 색상을 조정할지 여부. 🌈", 116 | "name": "adapt_color" 117 | }, 118 | "prefer_rgb_color": { 119 | "description": "가능할 경우 색온도 조정보다 RGB 색상 조정을 선호하는지 여부. 🌈", 120 | "name": "prefer_rgb_color" 121 | }, 122 | "turn_on_lights": { 123 | "description": "현재 꺼져 있는 조명을 켤지 여부. 🔆", 124 | "name": "turn_on_lights" 125 | } 126 | } 127 | }, 128 | "set_manual_control": { 129 | "name": "수동 제어 설정", 130 | "description": "조명이 '수동 제어됨'으로 표시되었는지 여부를 표시합니다.", 131 | "fields": { 132 | "entity_id": { 133 | "description": "`수동 제어됨`으로 (표시 해제)할 스위치의 `entity_id`. 📝", 134 | "name": "entity_id" 135 | }, 136 | "lights": { 137 | "description": "조명의 entity_id(들), 지정하지 않으면 스위치의 모든 조명이 선택됩니다. 💡", 138 | "name": "lights" 139 | }, 140 | "manual_control": { 141 | "description": "\"수동 제어\" 목록에서 조명을 추가(\"true\") 또는 제거(\"false\")할지 여부. 🔒", 142 | "name": "manual_control" 143 | } 144 | } 145 | }, 146 | "change_switch_settings": { 147 | "name": "스위치 설정 변경", 148 | "description": "스위치에서 원하는 모든 설정을 변경하세요. 여기에 있는 모든 옵션은 구성 흐름에서와 같습니다.", 149 | "fields": { 150 | "entity_id": { 151 | "description": "스위치의 Entity ID. 📝", 152 | "name": "entity_id" 153 | }, 154 | "use_defaults": { 155 | "description": "이 서비스 호출에서 지정되지 않은 기본값을 설정합니다. 옵션: \"현재\"(기본값, 현재 값을 유지), \"공장\"(문서화된 기본값으로 재설정), 또는 \"구성\"(스위치 구성 기본값으로 되돌림). ⚙️", 156 | "name": "use_defaults" 157 | }, 158 | "include_config_in_attributes": { 159 | "description": "`true`로 설정하면 Home Assistant에서 스위치의 모든 옵션을 속성으로 표시합니다. 📝", 160 | "name": "include_config_in_attributes" 161 | }, 162 | "turn_on_lights": { 163 | "description": "현재 꺼져 있는 조명을 켤지 여부. 🔆", 164 | "name": "turn_on_lights" 165 | }, 166 | "initial_transition": { 167 | "description": "조명이 `off`에서 `on`으로 바뀔 때 첫 번째 전환의 지속 시간, 초 단위. ⏲️", 168 | "name": "initial_transition" 169 | }, 170 | "sleep_transition": { 171 | "description": "\"수면 모드\"가 전환될 때 전환 기간, 초 단위. 😴", 172 | "name": "sleep_transition" 173 | }, 174 | "max_brightness": { 175 | "description": "최대 밝기 퍼센트. 💡", 176 | "name": "max_brightness" 177 | }, 178 | "max_color_temp": { 179 | "description": "켈빈으로 표시된 가장 차가운 색온도. ❄️", 180 | "name": "max_color_temp" 181 | }, 182 | "min_brightness": { 183 | "description": "최소 밝기 퍼센트. 💡", 184 | "name": "min_brightness" 185 | }, 186 | "min_color_temp": { 187 | "description": "켈빈으로 표시된 가장 따뜻한 색온도. 🔥", 188 | "name": "min_color_temp" 189 | }, 190 | "only_once": { 191 | "description": "조명을 켤 때만 조정 (`true`) 또는 계속해서 조정 (`false`). 🔄", 192 | "name": "only_once" 193 | }, 194 | "prefer_rgb_color": { 195 | "description": "가능할 경우 색온도 조정보다 RGB 색상 조정을 선호하는지 여부. 🌈", 196 | "name": "prefer_rgb_color" 197 | }, 198 | "separate_turn_on_commands": { 199 | "description": "일부 조명 유형에 필요한 색상과 밝기에 대해 별도의 `light.turn_on` 호출을 사용합니다. 🔀", 200 | "name": "separate_turn_on_commands" 201 | }, 202 | "send_split_delay": { 203 | "description": "밝기와 색상을 동시에 설정하지 않는 조명에 대한 `separate_turn_on_commands` 호출 사이의 지연 시간(밀리초). ⏲️", 204 | "name": "send_split_delay" 205 | }, 206 | "sleep_brightness": { 207 | "description": "수면 모드에서 조명의 밝기 퍼센트. 😴", 208 | "name": "sleep_brightness" 209 | }, 210 | "sleep_rgb_or_color_temp": { 211 | "description": "수면 모드에서 `\"rgb_color\"` 또는 `\"color_temp\"` 사용. 🌙", 212 | "name": "sleep_rgb_or_color_temp" 213 | }, 214 | "sleep_rgb_color": { 215 | "description": "수면 모드에서 RGB 색상 (sleep_rgb_or_color_temp가 \"rgb_color\"일 때 사용). 🌈", 216 | "name": "sleep_rgb_color" 217 | }, 218 | "sleep_color_temp": { 219 | "description": "수면 모드에서 색온도 (sleep_rgb_or_color_temp가 `color_temp`일 때 사용) 켈빈 단위. 😴", 220 | "name": "sleep_color_temp" 221 | }, 222 | "sunrise_offset": { 223 | "description": "양수 또는 음수 오프셋(초)으로 일출 시간을 조정. ⏰", 224 | "name": "sunrise_offset" 225 | }, 226 | "sunrise_time": { 227 | "description": "일출 시간을 고정된 시간 (HH:MM:SS)으로 설정. 🌅", 228 | "name": "sunrise_time" 229 | }, 230 | "sunset_offset": { 231 | "description": "양수 또는 음수 오프셋(초)으로 일몰 시간을 조정. ⏰", 232 | "name": "sunset_offset" 233 | }, 234 | "sunset_time": { 235 | "description": "일몰 시간을 고정된 시간 (HH:MM:SS)으로 설정. 🌇", 236 | "name": "sunset_time" 237 | }, 238 | "max_sunrise_time": { 239 | "description": "가장 늦은 가상 일출 시간 (HH:MM:SS)을 설정하여 더 일찍 일출을 허용. 🌅", 240 | "name": "max_sunrise_time" 241 | }, 242 | "min_sunset_time": { 243 | "description": "가장 이른 가상 일몰 시간 (HH:MM:SS)을 설정하여 더 늦은 일몰을 허용. 🌇", 244 | "name": "min_sunset_time" 245 | }, 246 | "take_over_control": { 247 | "description": "다른 소스가 조명이 켜져 있고 조정 중일 때 `light.turn_on`을 호출하면 적응형 조명을 비활성화합니다. 이는 매 `간격`마다 `homeassistant.update_entity`를 호출합니다! 🔒", 248 | "name": "take_over_control" 249 | }, 250 | "detect_non_ha_changes": { 251 | "description": "`light.turn_on`이 아닌 상태 변경을 감지하고 조정을 중단합니다. `take_over_control`이 활성화되어 있어야 합니다. 🕵️ 주의: ⚠️ 일부 조명은 잘못된 '켜짐' 상태를 나타낼 수 있으며, 이로 인해 조명이 예상치 못하게 켜질 수 있습니다. 이러한 문제가 발생하면 이 기능을 비활성화하세요.", 252 | "name": "detect_non_ha_changes" 253 | }, 254 | "transition": { 255 | "description": "조명이 변경될 때 전환 기간, 초 단위. 🕑", 256 | "name": "transition" 257 | }, 258 | "adapt_delay": { 259 | "description": "조명을 켠 후 적응형 조명이 변경 사항을 적용하기까지의 대기 시간(초). 깜박임을 피하는 데 도움이 될 수 있습니다. ⏲️", 260 | "name": "adapt_delay" 261 | }, 262 | "autoreset_control_seconds": { 263 | "description": "특정 초 후에 수동 제어를 자동으로 재설정. 0으로 설정하면 비활성화됩니다. ⏲️", 264 | "name": "autoreset_control_seconds" 265 | } 266 | } 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /custom_components/adaptive_lighting/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Adaptiv Belysning", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Vælg et navn for denne Adaptive Belysning", 7 | "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!", 8 | "data": { 9 | "name": "Navn" 10 | } 11 | } 12 | }, 13 | "abort": { 14 | "already_configured": "Denne enhed er allerede konfigureret" 15 | } 16 | }, 17 | "options": { 18 | "step": { 19 | "init": { 20 | "title": "Adaptiv Belysnings indstillinger", 21 | "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.", 22 | "data": { 23 | "lights": "lights: lyskilder", 24 | "initial_transition": "initial_transition: Hvor lang overgang når lyset går fra 'off' til 'on' eller når 'sleep_state' skiftes. (i sekunder)", 25 | "interval": "interval: Tid imellem opdateringer (i sekunder)", 26 | "max_brightness": "max_brightness: Højeste lysstyrke i cyklussen. (%)", 27 | "max_color_temp": "max_color_temp: Koldeste lystemperatur i cyklussen. (Kelvin)", 28 | "min_brightness": "min_brightness: Laveste lysstyrke i cyklussen. (%)", 29 | "min_color_temp": "min_color_temp: Varmeste lystemperatur i cyklussen. (Kelvin)", 30 | "only_once": "only_once: Juster udelukkende lysene adaptivt i øjeblikket de tændes.", 31 | "prefer_rgb_color": "prefer_rgb_color: Brug 'rgb_color' istedet for 'color_temp' når muligt.", 32 | "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).", 33 | "sleep_brightness": "sleep_brightness, Lysstyrke for Sleep Mode. (%)", 34 | "sleep_color_temp": "sleep_color_temp: Farvetemperatur under Sleep Mode. (Kelvin)", 35 | "sunrise_offset": "sunrise_offset: Hvor længe før (-) eller efter (+) at definere solopgangen i cyklussen (+/- sekunder)", 36 | "sunrise_time": "sunrise_time: Manuel overstyring af solopgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt din lokation. (HH:MM:SS)", 37 | "sunset_offset": "sunset_offset: Hvor længe før (-) eller efter (+) at definere solnedgangen i cyklussen (+/- sekunder)", 38 | "sunset_time": "sunset_time: Manuel overstyring af solnedgangstidspunktet, hvis 'None', bruges det egentlige tidspunkt for din lokation. (HH:MM:SS)", 39 | "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.", 40 | "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'!)", 41 | "transition": "Overgangsperiode når en ændring i lyset udføres (i sekunder)", 42 | "transition_until_sleep": "overgang_til_sove: Når aktiveret, vil adaptiv belysning behandle søvnindstillinger som minimum, og overgår til disse værdier efter solnedgang. 🌙", 43 | "adapt_only_on_bare_turn_on": "tilpas_kun_ved_enkelt_tænd: Når du tænder lys for første gang. Hvis indstillet til 'true', tilpasser AL kun, hvis 'lys.tænd' er kaldt uden at angive farve eller lysstyrke. ❌🌈 Dette forhindrer f.eks. tilpasning, når du aktiverer en scene. Hvis indstillet til 'false' tilpasser AL sig uanset tilstanden af farve eller lysstyrke i den oprindelige 'service_data'. Har brug for at 'take_over_control' er aktiveret. 🕵️", 44 | "include_config_in_attributes": "include_config_in_attributes: Vis alle indstillinger som attributter for kontakten når dette er sat til »true«. 📝", 45 | "skip_redundant_commands": "skip_redundant_commands: Undlad at sende tilpasningskommando, hvis lampens kendte tilstand allerede er lig den ønskede tilstand. Mindsker mængden af netværkstrafik og forbedrer tilpasningens responsivitet i visse situationer. 📉 Slå fra, hvis lampens faktiske tilstand kommer ud af takt med den tilstand, som HA rapporterer.", 46 | "intercept": "intercept: Indfang og tilpas »light.turn_on«-kald for at muliggøre øjeblikkelig farve- og lysstyrketilpasning. 🏎️ Slå fra for lyskilder, som ikke understøtter »light.turn_on« med farve og lysstyrke.", 47 | "multi_light_intercept": "multi_light_intercept: Indfang og tilpas »light.turn_on«-kald til mere end en enkelt lyskilde. ➗⚠️ Dette kan bevirke at et enkelt »light.turn_on«-kald deles op i flere, f.eks. hvis lyskilderne er forbundet til forskellige kontakter. Forudsætter at »intercept« er slået til." 48 | }, 49 | "data_description": { 50 | "interval": "Frekvens til at tilpasse lysene, i sekunder. 🔄", 51 | "sleep_brightness": "Lysstyrkeprocent af lys i søvntilstand. 😴", 52 | "transition": "Varighed af overgang, når lys ændres, i sekunder. 🕑", 53 | "sleep_rgb_or_color_temp": "Brug enten `\"rgb_farve\"` eller `\"farve_temp\"` i søvntilstand. 🌙", 54 | "sleep_transition": "Varigheden af overgangen, når \"sovetilstand\" skiftes, i sekunder. 😴", 55 | "sunrise_time": "Sæt en fast tid (HH:MM:SS) for solopgang. 🌅", 56 | "sunset_time": "Sæt en fast tid (HH:MM:SS) for solnedgang. 🌇", 57 | "min_sunrise_time": "Indstil den tidligste virtuelle solopgangstid (HH:MM:SS), hvilket giver mulighed for senere solopgange. 🌅", 58 | "max_sunrise_time": "Indstil den seneste virtuelle solopgangstid (HH:MM:SS), hvilket giver mulighed for tidligere solopgange. 🌅", 59 | "autoreset_control_seconds": "Nulstil automatisk den manuelle styring efter et antal sekunder. Indstil til 0 for at deaktivere. ⏲️", 60 | "min_sunset_time": "Indstil den tidligste virtuelle solnedgangstid (HH:MM:SS), hvilket giver mulighed for senere solnedgange. 🌇", 61 | "adapt_delay": "Ventetid (sekunder) mellem lyset tændes og Adaptive Lighting anvender ændringer. Kan hjælpe med at undgå flimren. ⏲️", 62 | "sunset_offset": "Juster solnedgang tid med et positivt eller negativt offset, i sekunder. ⏰", 63 | "sunrise_offset": "Juster solopgangstiden med en positiv eller negativ offset på få sekunder. ⏰", 64 | "max_sunset_time": "Indstil den seneste virtuelle solnedgangstid (HH:MM:SS), hvilket giver mulighed for tidligere solnedgange. 🌇", 65 | "sleep_color_temp": "Farvetemperatur i søvntilstand (bruges når `sleep_rgb_or_color_temp` er `color_temp`) i Kelvin. 😴", 66 | "brightness_mode": "Lysstyrketilstand til brug. Mulige værdier er \"default\", \"linear\" og \"tanh\" (bruger \"brightness_mode_time_dark\" og \"brightness_mode_time_light\"). 📈", 67 | "send_split_delay": "Forsinkelse (ms) mellem »separate_turn_on_commands« for lyskilder som ikke understøtter simultane styrke- og farveindstillinger. ⏲️", 68 | "initial_transition": "Den første overgangs varighed når lysene ændres fra »off« til »on« i sekunder. ⏲️", 69 | "sleep_rgb_color": "RGB-farve i søvntilstand (anvendes når »sleep_rgb_or_color_temp« er sat til »rgb_color«). 🌈", 70 | "brightness_mode_time_dark": "(Ignoreres hvis »brightness_mode='default'«) Varigheden i sekunder for tilpasningen af lysstyrken ved solopgang eller -nedgang. 📈📉", 71 | "brightness_mode_time_light": "(Ignoreres hvis »brightness_mode='default'«) Varigheden i sekunder for tilpasningen af lysstyrken ved solopgang eller -nedgang. 📈📉" 72 | } 73 | } 74 | }, 75 | "error": { 76 | "option_error": "Ugyldig indstilling", 77 | "entity_missing": "Et udvalgt lys blev ikke fundet " 78 | } 79 | }, 80 | "services": { 81 | "apply": { 82 | "description": "Anvender de aktuelle Adaptive Lighting indstillinger på lys.", 83 | "fields": { 84 | "prefer_rgb_color": { 85 | "description": "Om man vil foretrække RGB-farvejustering frem for lysfarvetemperatur, når det er muligt. 🌈" 86 | }, 87 | "transition": { 88 | "description": "Varighed af overgang, når lys ændres, i sekunder. 🕑" 89 | }, 90 | "turn_on_lights": { 91 | "description": "Om lys der i øjeblikket er slukket, skal tændes. 🔆" 92 | }, 93 | "adapt_brightness": { 94 | "description": "Om lysstyrken skal tilpasses. 🌞" 95 | }, 96 | "lights": { 97 | "description": "Et lys (eller liste over lys) som indstillingerne skal anvendes til. 💡" 98 | }, 99 | "adapt_color": { 100 | "description": "Om farven på støttelys skal tilpasses. 🌈" 101 | } 102 | } 103 | }, 104 | "change_switch_settings": { 105 | "fields": { 106 | "entity_id": { 107 | "description": "Entity ID af kontakten. 📝" 108 | }, 109 | "turn_on_lights": { 110 | "description": "Om lys der i øjeblikket er slukket, skal tændes. 🔆" 111 | }, 112 | "sleep_transition": { 113 | "description": "Varigheden af overgangen, når \"sovetilstand\" skiftes, i sekunder. 😴" 114 | }, 115 | "only_once": { 116 | "description": "Tilpas kun lys, når de er tændt ('sand'), eller fortsæt med at tilpasse dem ('falsk'). 🔄" 117 | }, 118 | "prefer_rgb_color": { 119 | "description": "Om man vil foretrække RGB-farvejustering frem for lysfarvetemperatur, når det er muligt. 🌈" 120 | }, 121 | "sleep_brightness": { 122 | "description": "Lysstyrkeprocent af lys i søvntilstand. 😴" 123 | }, 124 | "sunrise_time": { 125 | "description": "Sæt en fast tid (HH:MM:SS) til solopgang. 🌅" 126 | }, 127 | "sunrise_offset": { 128 | "description": "Juster solopgangstiden med et positivt eller negativt offset, i sekunder. ⏰" 129 | }, 130 | "sunset_offset": { 131 | "description": "Juster solnedgang tid med et positivt eller negativt offset, i sekunder. ⏰" 132 | }, 133 | "sunset_time": { 134 | "description": "Sæt en fast tid (HH:MM:SS) for solnedgang. 🌇" 135 | }, 136 | "max_sunrise_time": { 137 | "description": "Indstil den seneste virtuelle solopgangstid (HH:MM:SS), hvilket giver mulighed for tidligere solopgange. 🌅" 138 | }, 139 | "min_sunset_time": { 140 | "description": "Indstil den tidligste virtuelle solnedgangstid (HH:MM:SS), hvilket giver mulighed for senere solnedgange. 🌇" 141 | }, 142 | "transition": { 143 | "description": "Varighed af overgang, når lys ændres, i sekunder. 🕑" 144 | }, 145 | "autoreset_control_seconds": { 146 | "description": "Nulstil automatisk den manuelle styring efter et antal sekunder. Indstil til 0 for at deaktivere. ⏲️" 147 | }, 148 | "adapt_delay": { 149 | "description": "Ventetid (sekunder) mellem lyset tændes og Adaptive Lighting anvender ændringer. Kan hjælpe med at undgå flimren. ⏲️" 150 | }, 151 | "max_brightness": { 152 | "description": "Maksimal lysstyrkeprocent. 💡" 153 | }, 154 | "max_color_temp": { 155 | "description": "Koldeste farvetemperatur i Kelvin. ❄️" 156 | }, 157 | "min_brightness": { 158 | "description": "Mindste lysstyrkeprocent. 💡" 159 | }, 160 | "min_color_temp": { 161 | "description": "Varmste farvetemperatur i Kelvin. 🔥" 162 | }, 163 | "sleep_color_temp": { 164 | "description": "Farvetemperatur i søvntilstand (bruges når `sleep_rgb_or_color_temp` er `color_temp`) i Kelvin. 😴" 165 | }, 166 | "send_split_delay": { 167 | "description": "Forsinkelse (ms) mellem »separate_turn_on_commands« for lyskilder som ikke understøtter simultane styrke- og farveindstillinger. ⏲️" 168 | }, 169 | "detect_non_ha_changes": { 170 | "description": "Opdager og stopper tilpasningen ved tilstandsændringer, som ikke er udløst af »light.turn_on«. Indstillingen »take_over_control« skal være aktiveret. 🕵️ Advarsel: ⚠️ Nogle lyskilder kan rapportere en falsk tændt-tilstand, hvilket kan medføre af lyskilden tændes når det ikke er meningen. Slå denne funktion fra, hvis du oplever dette problem." 171 | }, 172 | "initial_transition": { 173 | "description": "Den første overgangs varighed når lysene ændres fra »off« til »on« i sekunder. ⏲️" 174 | } 175 | }, 176 | "description": "Skift de indstillinger du ønsker i kontakten. Alle muligheder her er de samme som i konfigurationsflowet." 177 | }, 178 | "set_manual_control": { 179 | "description": "Markér om et lys er 'manuelt kontrolleret'.", 180 | "fields": { 181 | "lights": { 182 | "description": "entity_id(er) af lys, hvis ikke specificeret, vil alle lys i kontakten være valgt. 💡" 183 | } 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /webapp/app.py: -------------------------------------------------------------------------------- 1 | """Simple web app to visualize brightness over time.""" 2 | 3 | import datetime as dt 4 | from contextlib import suppress 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import shinyswatch 11 | from astral import LocationInfo 12 | from astral.location import Location 13 | from homeassistant_util_color import color_temperature_to_rgb 14 | from shiny import App, render, ui 15 | 16 | 17 | def date_range(tzinfo: dt.tzinfo) -> list[dt.datetime]: 18 | """Return a list of datetimes for the current day.""" 19 | start_of_day = dt.datetime.now(tzinfo).replace( 20 | hour=0, 21 | minute=0, 22 | second=0, 23 | microsecond=0, 24 | ) 25 | # one second before the next day 26 | end_of_day = start_of_day + dt.timedelta(days=1) - dt.timedelta(seconds=1) 27 | hours_range = [start_of_day] 28 | while hours_range[-1] < end_of_day: 29 | hours_range.append(hours_range[-1] + dt.timedelta(minutes=1)) 30 | return hours_range[:-1] 31 | 32 | 33 | def copy_color_and_brightness_module() -> None: 34 | """Copy the color_and_brightness module to the webapp folder.""" 35 | with suppress(Exception): 36 | webapp_folder = Path(__file__).parent.absolute() 37 | module = ( 38 | webapp_folder.parent 39 | / "custom_components" 40 | / "adaptive_lighting" 41 | / "color_and_brightness.py" 42 | ) 43 | new_module = webapp_folder / module.name 44 | with module.open() as f: 45 | lines = [ 46 | line.replace("homeassistant.util.color", "homeassistant_util_color") 47 | for line in f.readlines() 48 | ] 49 | with new_module.open("r") as f: 50 | existing_lines = f.readlines() 51 | if existing_lines != lines: 52 | with new_module.open("w") as f: 53 | f.writelines(lines) 54 | 55 | 56 | copy_color_and_brightness_module() 57 | 58 | from color_and_brightness import SunLightSettings # noqa: E402 59 | 60 | 61 | def plot_brightness(inputs: dict[str, Any], sleep_mode: bool): 62 | """Plot the brightness over time for different modes.""" 63 | # Define the time range for our simulation 64 | sun_linear = SunLightSettings(**inputs, brightness_mode="linear") 65 | sun_tanh = SunLightSettings(**inputs, brightness_mode="tanh") 66 | sun = SunLightSettings(**inputs, brightness_mode="default") 67 | # Calculate the brightness for each time in the time range for all modes 68 | dt_range = date_range(sun.timezone) 69 | time_range = [time_to_float(dt) for dt in dt_range] 70 | brightness_linear_values = [ 71 | sun_linear.brightness_pct(dt, sleep_mode) for dt in dt_range 72 | ] 73 | brightness_tanh_values = [ 74 | sun_tanh.brightness_pct(dt, sleep_mode) for dt in dt_range 75 | ] 76 | brightness_default_values = [sun.brightness_pct(dt, sleep_mode) for dt in dt_range] 77 | 78 | # Plot the brightness over time for both modes 79 | fig, ax = plt.subplots(figsize=(10, 6)) 80 | ax.plot(time_range, brightness_linear_values, label="Linear Mode") 81 | ax.plot(time_range, brightness_tanh_values, label="Tanh Mode") 82 | ax.plot(time_range, brightness_default_values, label="Default Mode", c="C5") 83 | sunrise_time = sun.sun.sunrise(dt.date.today()) 84 | sunset_time = sun.sun.sunset(dt.date.today()) 85 | ax.vlines( 86 | time_to_float(sunrise_time), 87 | 0, 88 | 100, 89 | color="C2", 90 | label="Sunrise", 91 | linestyles="dashed", 92 | ) 93 | ax.vlines( 94 | time_to_float(sunset_time), 95 | 0, 96 | 100, 97 | color="C3", 98 | label="Sunset", 99 | linestyles="dashed", 100 | ) 101 | ax.set_xlim(0, 24) 102 | ax.set_xticks(np.arange(0, 25, 1)) 103 | yticks = np.arange(0, 105, 5) 104 | ytick_labels = [f"{label:.0f}%" for label in yticks] 105 | ax.set_yticks(yticks, ytick_labels) 106 | ax.set_xlabel("Time (hours)") 107 | ax.set_ylabel("Brightness") 108 | ax.set_title("Brightness over Time for Different Modes") 109 | 110 | # Add text box 111 | textstr = "\n".join( 112 | ( 113 | f"Sunrise Time = {sunrise_time.time()}", 114 | f"Sunset Time = {sunset_time.time()}", 115 | f"Max Brightness = {sun.max_brightness:.0f}%", 116 | f"Min Brightness = {sun.min_brightness:.0f}%", 117 | f"Time Light = {sun.brightness_mode_time_light}", 118 | f"Time Dark = {sun.brightness_mode_time_dark}", 119 | ), 120 | ) 121 | 122 | ax.legend() 123 | 124 | ax.text( 125 | 0.4, 126 | 0.55, 127 | textstr, 128 | transform=ax.transAxes, 129 | fontsize=10, 130 | verticalalignment="center", 131 | bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5}, 132 | ) 133 | ax.grid(visible=True) 134 | 135 | return fig 136 | 137 | 138 | def plot_color_temp(inputs: dict[str, Any], sleep_mode: bool) -> plt.Figure: 139 | """Plot the color temperature over time for different modes.""" 140 | sun = SunLightSettings(**inputs, brightness_mode="default") 141 | dt_range = date_range(tzinfo=sun.timezone) 142 | time_range = [time_to_float(dt) for dt in dt_range] 143 | settings = [sun.brightness_and_color(dt, sleep_mode) for dt in dt_range] 144 | if sleep_mode and sun.sleep_rgb_or_color_temp == "color_temp": 145 | colors = [ 146 | color_temperature_to_rgb(setting["color_temp_kelvin"]) 147 | for setting in settings 148 | ] 149 | else: 150 | colors = [setting["rgb_color"] for setting in settings] 151 | color_temp_values = np.array([(*col, 255) for col in colors]) / 255 152 | color_temp_values = color_temp_values.reshape(-1, 1, 4) 153 | sun_position = [setting["sun_position"] for setting in settings] 154 | fig, ax = plt.subplots(figsize=(10, 6)) 155 | 156 | # Display as a horizontal bar 157 | ax.imshow( 158 | np.rot90(color_temp_values)[:, ::1], 159 | aspect="auto", 160 | extent=[0, 24, -1, 1], 161 | origin="upper", 162 | ) 163 | # Plot a curve on top of the imshow 164 | ax.plot(time_range, sun_position, color="k", label="Sun Position") 165 | 166 | sunrise_time = sun.sun.sunrise(dt.date.today()) 167 | sunset_time = sun.sun.sunset(dt.date.today()) 168 | ax.vlines( 169 | time_to_float(sunrise_time), 170 | -1, 171 | 1, 172 | color="C2", 173 | label="Sunrise", 174 | linestyles="dashed", 175 | ) 176 | ax.vlines( 177 | time_to_float(sunset_time), 178 | -1, 179 | 1, 180 | color="C3", 181 | label="Sunset", 182 | linestyles="dashed", 183 | ) 184 | 185 | ax.set_xlim(0, 24) 186 | ax.set_xticks(np.arange(0, 25, 1)) 187 | yticks = np.arange(-1, 1.1, 0.1) 188 | ax.set_yticks(yticks, [f"{label*100:.0f}%" for label in yticks]) 189 | ax.set_xlabel("Time (hours)") 190 | ax.legend() 191 | ax.set_ylabel("Sun position (%)") 192 | ax.set_title("RGB Color Intensity over Time") 193 | ax.grid(visible=False) 194 | return fig 195 | 196 | 197 | SEC_PER_HR = 60 * 60 198 | desc_top = """ 199 | **Experience the Dynamics of [Adaptive Lighting](https://github.com/basnijholt/adaptive-lighting) in Real-Time.** 200 | 201 | Have you ever wondered how the intricate settings of [Adaptive Lighting](https://github.com/basnijholt/adaptive-lighting) impact your home ambiance? The Adaptive Lighting Simulator WebApp is here to demystify just that. 202 | 203 | (More text below the plots) 204 | """ 205 | 206 | desc_bottom = """ 207 | Harnessing the technology of the popular Adaptive Lighting integration for Home Assistant, this webapp provides a hands-on, visual platform to explore, tweak, and understand the myriad of parameters that dictate the behavior of your smart lights. Whether you're aiming for a subtle morning glow or a cozy evening warmth, observe firsthand how each tweak changes the ambiance. 208 | 209 | **Why Use the Simulator?** 210 | - **Interactive Exploration**: No more guesswork. See in real-time how changes to settings influence the lighting dynamics. 211 | - **Circadian Cycle Preview**: Understand how Adaptive Lighting adjusts throughout the day based on specific parameters, ensuring your lighting aligns with your circadian rhythms. 212 | - **Tailored Testing**: Play with parameters and find the perfect combination that suits your personal or family's needs. 213 | - **Educational Experience**: For both newbies and experts, delve deep into the intricacies of Adaptive Lighting's logic and potential. 214 | 215 | Dive into the simulator, experiment with different settings, and fine-tune the behavior of Adaptive Lighting to perfection. Whether you're setting it up for the first time or optimizing an existing setup, this tool ensures you get the most out of your smart lighting experience. 216 | """ 217 | 218 | # Shiny UI 219 | app_ui = ui.page_fluid( 220 | ui.panel_title("🌞 Adaptive Lighting Simulator WebApp 🌛"), 221 | ui.layout_sidebar( 222 | ui.sidebar( 223 | ui.input_switch("adapt_until_sleep", "adapt_until_sleep", value=False), 224 | ui.input_switch("sleep_mode", "sleep_mode", value=False), 225 | ui.input_slider("min_brightness", "min_brightness", 1, 100, 30, post="%"), 226 | ui.input_slider("max_brightness", "max_brightness", 1, 100, 100, post="%"), 227 | ui.input_numeric("min_color_temp", "min_color_temp", 2000), 228 | ui.input_numeric("max_color_temp", "max_color_temp", 6666), 229 | ui.input_slider( 230 | "sleep_brightness", 231 | "sleep_brightness", 232 | 1, 233 | 100, 234 | 1, 235 | post="%", 236 | ), 237 | ui.input_radio_buttons( 238 | "sleep_rgb_or_color_temp", 239 | "sleep_rgb_or_color_temp", 240 | ["rgb_color", "color_temp"], 241 | ), 242 | ui.input_numeric("sleep_color_temp", "sleep_color_temp", 2000), 243 | ui.input_text("sleep_rgb_color", "sleep_rgb_color", "255,0,0"), 244 | ui.input_slider( 245 | "brightness_mode_time_dark", 246 | "brightness_mode_time_dark", 247 | 1, 248 | 5 * SEC_PER_HR, 249 | 3 * SEC_PER_HR, 250 | post=" sec", 251 | ), 252 | ui.input_slider( 253 | "brightness_mode_time_light", 254 | "brightness_mode_time_light", 255 | 1, 256 | 5 * SEC_PER_HR, 257 | 0.5 * SEC_PER_HR, 258 | post=" sec", 259 | ), 260 | ui.input_slider( 261 | "sunrise_time", 262 | "sunrise_time", 263 | 0, 264 | 24, 265 | 6, 266 | step=0.5, 267 | post=" hr", 268 | ), 269 | ui.input_slider( 270 | "sunset_time", 271 | "sunset_time", 272 | 0, 273 | 24, 274 | 18, 275 | step=0.5, 276 | post=" hr", 277 | ), 278 | ), 279 | ui.markdown(desc_top), 280 | ui.output_plot(id="brightness_plot"), 281 | ui.output_plot(id="color_temp_plot"), 282 | ui.markdown(desc_bottom), 283 | ), 284 | theme=shinyswatch.theme.sandstone, 285 | ) 286 | 287 | 288 | def float_to_time(value: float) -> dt.time: 289 | """Convert a float to a time object.""" 290 | hours = int(value) 291 | minutes = int((value - hours) * 60) 292 | return dt.time(hours, minutes) 293 | 294 | 295 | def time_to_float(time: dt.time | dt.datetime) -> float: 296 | """Convert a time object to a float.""" 297 | return time.hour + time.minute / 60 298 | 299 | 300 | def _kw(input): 301 | location = Location(LocationInfo(timezone=dt.timezone.utc)) 302 | return { 303 | "name": "Adaptive Lighting Simulator", 304 | "adapt_until_sleep": input.adapt_until_sleep(), 305 | "max_brightness": input.max_brightness(), 306 | "min_brightness": input.min_brightness(), 307 | "min_color_temp": input.min_color_temp(), 308 | "max_color_temp": input.max_color_temp(), 309 | "sleep_brightness": input.sleep_brightness(), 310 | "sleep_rgb_or_color_temp": input.sleep_rgb_or_color_temp(), 311 | "sleep_color_temp": input.sleep_color_temp(), 312 | "sleep_rgb_color": [int(x) for x in input.sleep_rgb_color().split(",")], 313 | "sunrise_time": float_to_time(input.sunrise_time()), 314 | "sunset_time": float_to_time(input.sunset_time()), 315 | "brightness_mode_time_dark": dt.timedelta( 316 | seconds=input.brightness_mode_time_dark(), 317 | ), 318 | "brightness_mode_time_light": dt.timedelta( 319 | seconds=input.brightness_mode_time_light(), 320 | ), 321 | "sunrise_offset": dt.timedelta(0), 322 | "sunset_offset": dt.timedelta(0), 323 | "min_sunrise_time": None, 324 | "max_sunrise_time": None, 325 | "min_sunset_time": None, 326 | "max_sunset_time": None, 327 | "astral_location": location, 328 | "timezone": location.timezone, 329 | } 330 | 331 | 332 | def server(input, output, session): # noqa: ARG001 333 | """Shiny server.""" 334 | 335 | @output 336 | @render.plot 337 | def brightness_plot(): 338 | return plot_brightness(_kw(input), sleep_mode=input.sleep_mode()) 339 | 340 | @output 341 | @render.plot 342 | def color_temp_plot(): 343 | return plot_color_temp(_kw(input), sleep_mode=input.sleep_mode()) 344 | 345 | 346 | app = App(app_ui, server) 347 | --------------------------------------------------------------------------------