├── .gitattributes ├── tests ├── __init__.py └── components │ ├── __init__.py │ └── lightener │ ├── __init__.py │ ├── configuration.yaml │ ├── conftest.py │ ├── test_init.py │ ├── test_config_flow.py │ └── test_light.py ├── images ├── icon.png ├── icon@2x.png ├── dark_icon.png ├── dark_logo.png ├── dark_icon@2x.png ├── dark_logo@2x.png ├── lightener-example.gif └── lightener.svg ├── scripts ├── lint ├── develop └── setup ├── custom_components └── lightener │ ├── const.py │ ├── manifest.json │ ├── util.py │ ├── translations │ ├── sk.json │ ├── en.json │ └── pt-BR.json │ ├── __init__.py │ ├── config_flow.py │ └── light.py ├── pyproject.toml ├── requirements.txt ├── hacs.json ├── setup.cfg ├── .github └── workflows │ ├── hassfest.yml │ ├── hacs.yml │ ├── lint.yml │ ├── validate.yml │ └── release.yml ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .gitignore ├── LICENSE ├── .devcontainer.json ├── .ruff.toml ├── CONTRIBUTING.md ├── config └── configuration.yaml └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests.""" 2 | -------------------------------------------------------------------------------- /tests/components/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for components.""" 2 | -------------------------------------------------------------------------------- /tests/components/lightener/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Lightener.""" 2 | -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/icon@2x.png -------------------------------------------------------------------------------- /images/dark_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/dark_icon.png -------------------------------------------------------------------------------- /images/dark_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/dark_logo.png -------------------------------------------------------------------------------- /images/dark_icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/dark_icon@2x.png -------------------------------------------------------------------------------- /images/dark_logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/dark_logo@2x.png -------------------------------------------------------------------------------- /images/lightener-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredck/lightener/HEAD/images/lightener-example.gif -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /custom_components/lightener/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Lightener component.""" 2 | 3 | DOMAIN = "lightener" 4 | 5 | TYPE_DIMMABLE = "dimmable" 6 | TYPE_ONOFF = "on_off" 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | include = '\\.pyi?$' 4 | exclude = [ 5 | '/\.git', 6 | '/__pycache__', 7 | '/build', 8 | '/dist' 9 | ] 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog>=6.9.0 2 | homeassistant==2025.10.1 3 | pip>=25.2 4 | ruff==0.14.0 5 | 6 | # For tests 7 | pytest==8.4.2 8 | pytest-asyncio==1.2.0 9 | pytest_homeassistant_custom_component==0.13.286 -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lightener", 3 | "filename": "lightener.zip", 4 | "hide_default_branch": true, 5 | "homeassistant": "2023.3.0", 6 | "render_readme": true, 7 | "zip_release": true 8 | } -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503 3 | max-line-length = 88 4 | exclude = 5 | .git, 6 | __pycache__, 7 | build, 8 | dist 9 | 10 | [tool:pytest] 11 | testpaths = tests/components/lightener 12 | asyncio_mode = auto 13 | 14 | [pylint.format] 15 | max-line-length = 120 16 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - uses: 'actions/checkout@v4' 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "esbenp.prettier-vscode", 5 | "ms-python.python", 6 | "ms-python.pylint", 7 | "ms-python.vscode-pylance", 8 | "visualstudioexptteam.vscodeintellicode", 9 | "redhat.vscode-yaml" 10 | ] 11 | } -------------------------------------------------------------------------------- /.github/workflows/hacs.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/lightener/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "lightener", 3 | "name": "Lightener", 4 | "codeowners": ["@fredck"], 5 | "config_flow": true, 6 | "documentation": "https://github.com/fredck/lightener#readme", 7 | "integration_type": "device", 8 | "iot_class": "local_push", 9 | "issue_tracker": "https://github.com/fredck/lightener/issues", 10 | "version": "2.4.0" 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | .venv 3 | __pycache__ 4 | .pytest* 5 | *.egg-info 6 | */build/* 7 | */dist/* 8 | 9 | # misc 10 | .DS_Store 11 | .coverage 12 | coverage.xml 13 | 14 | # Visual Studio Code 15 | .vscode/* 16 | !.vscode/extensions.json 17 | !.vscode/launch.json 18 | !.vscode/settings.json 19 | !.vscode/tasks.json 20 | 21 | # Home Assistant configuration 22 | config/* 23 | !config/configuration.yaml 24 | -------------------------------------------------------------------------------- /custom_components/lightener/util.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | 3 | from homeassistant.components.light import ( 4 | brightness_supported, 5 | get_supported_color_modes, 6 | ) 7 | from homeassistant.core import HomeAssistant 8 | 9 | from .const import TYPE_DIMMABLE, TYPE_ONOFF 10 | 11 | 12 | def get_light_type(hass: HomeAssistant, entity_id: str) -> str | None: 13 | """Return the type of light (TYPE_DIMMABLE or TYPE_ONOFF).""" 14 | 15 | supported_color_modes = get_supported_color_modes(hass, entity_id) 16 | 17 | return ( 18 | (TYPE_DIMMABLE if brightness_supported(supported_color_modes) else TYPE_ONOFF) 19 | if supported_color_modes 20 | else None 21 | ) 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | ruff: 13 | name: "Ruff" 14 | runs-on: "ubuntu-latest" 15 | steps: 16 | - name: "Checkout the repository" 17 | uses: "actions/checkout@v4" 18 | 19 | - name: "Set up Python" 20 | uses: actions/setup-python@v5.0.0 21 | with: 22 | python-version: "3.10" 23 | cache: "pip" 24 | 25 | - name: "Install requirements" 26 | run: python3 -m pip install -r requirements.txt 27 | 28 | - name: "Run" 29 | run: python3 -m ruff check . 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Home Assistant: Local", 6 | "type": "python", 7 | "request": "launch", 8 | "module": "homeassistant", 9 | "justMyCode": false, 10 | "args": [ 11 | "--debug", 12 | "--config", 13 | "${workspaceFolder}/config" 14 | ], 15 | "env": { 16 | "PYTHONPATH": "${workspaceFolder}" 17 | }, 18 | "console": "integratedTerminal" 19 | }, 20 | { 21 | "name": "Debug Unit Test", 22 | "type": "python", 23 | "request": "test", 24 | "justMyCode": false 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [ 3 | 120 4 | ], 5 | "editor.tabSize": 4, 6 | "editor.formatOnPaste": false, 7 | "editor.formatOnSave": true, 8 | "editor.formatOnType": true, 9 | "files.autoSave": "onFocusChange", 10 | "files.associations": { 11 | "*.yaml": "home-assistant" 12 | }, 13 | "files.eol": "\n", 14 | "files.trimTrailingWhitespace": true, 15 | "python.testing.unittestEnabled": false, 16 | "python.testing.pytestEnabled": true, 17 | "python.testing.pytestArgs": [], 18 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 19 | "[python]": { 20 | "editor.defaultFormatter": "charliermarsh.ruff", 21 | "editor.formatOnSave": true, 22 | "editor.codeActionsOnSave": { 23 | "source.fixAll": "explicit", 24 | "source.organizeImports": "explicit" 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | if [[ ! -d .venv ]]; then 8 | echo "Virtualenv not found. Run scripts/setup first." >&2 9 | exit 1 10 | fi 11 | 12 | . .venv/bin/activate 13 | 14 | # Create config dir if not present 15 | if [[ ! -d "${PWD}/config" ]]; then 16 | mkdir -p "${PWD}/config" 17 | python -m homeassistant --config "${PWD}/config" --script ensure_config 18 | fi 19 | 20 | # Set the path to custom_components 21 | ## This let's us have the structure we want /custom_components/lightener 22 | ## while at the same time have Home Assistant configuration inside /config 23 | ## without resulting to symlinks. 24 | export PYTHONPATH="${PYTHONPATH-}:${PWD}/custom_components" 25 | 26 | # Start Home Assistant 27 | python -m homeassistant --config "${PWD}/config" --debug 28 | -------------------------------------------------------------------------------- /tests/components/lightener/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Attention: This file is for development purpose only and can be ignored. 2 | 3 | # https://www.home-assistant.io/integrations/default_config/ 4 | default_config: 5 | 6 | # https://www.home-assistant.io/integrations/logger/ 7 | logger: 8 | default: info 9 | logs: 10 | custom_components.lightener: debug 11 | 12 | # Create a few fake lights take can be used in ha to create Lightener lights. 13 | light: 14 | - platform: template 15 | lights: 16 | test1: 17 | friendly_name: "Living Room Ceiling Lights" 18 | turn_on: 19 | turn_off: 20 | set_level: 21 | living_room_ceiling_leds: 22 | friendly_name: "Living Room Ceiling Leds" 23 | turn_on: 24 | turn_off: 25 | set_level: 26 | living_room_sofa_lamp: 27 | friendly_name: "Living Room Sofa Lamp" 28 | turn_on: 29 | turn_off: 30 | set_level: 31 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest 16 | name: "Hassfest Validation" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: "actions/checkout@v4" 21 | 22 | - name: "Run hassfest validation" 23 | uses: "home-assistant/actions/hassfest@master" 24 | 25 | hacs: # https://github.com/hacs/action 26 | name: "HACS Validation" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: "actions/checkout@v4" 31 | 32 | - name: "Run HACS validation" 33 | uses: "hacs/action@main" 34 | with: 35 | category: "integration" 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4" 19 | 20 | - name: "Adjust version number" 21 | shell: "bash" 22 | run: | 23 | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ 24 | "${{ github.workspace }}/custom_components/lightener/manifest.json" 25 | 26 | - name: "ZIP the integration directory" 27 | shell: "bash" 28 | run: | 29 | cd "${{ github.workspace }}/custom_components/lightener" 30 | zip lightener.zip -r ./ 31 | 32 | - name: "Upload the ZIP file to the release" 33 | uses: softprops/action-gh-release@v0.1.15 34 | with: 35 | files: ${{ github.workspace }}/custom_components/lightener/lightener.zip 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2023 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lightener Dev Container", 3 | "image": "mcr.microsoft.com/vscode/devcontainers/base:debian", 4 | "postCreateCommand": "scripts/setup", 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 | "charliermarsh.ruff", 18 | "esbenp.prettier-vscode", 19 | "ms-python.python", 20 | "ms-python.pylint", 21 | "ms-python.vscode-pylance", 22 | "visualstudioexptteam.vscodeintellicode", 23 | "redhat.vscode-yaml" 24 | ], 25 | "settings": { 26 | "python.defaultInterpreterPath": "/workspace/.venv/bin/python", 27 | "python.terminal.activateEnvironment": true, 28 | "terminal.integrated.defaultProfile.linux": "bash", 29 | "terminal.integrated.profiles.linux": { 30 | "bash": { 31 | "path": "/bin/bash" 32 | } 33 | } 34 | } 35 | } 36 | }, 37 | "remoteUser": "root", 38 | "workspaceFolder": "/workspace", 39 | "mounts": [ 40 | "source=${localWorkspaceFolder},target=/workspace,type=bind" 41 | ] 42 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Lint with Flake8", 6 | "type": "shell", 7 | "command": "flake8", 8 | "group": "build", 9 | "presentation": { 10 | "reveal": "always" 11 | } 12 | }, 13 | { 14 | "label": "Format with Black", 15 | "type": "shell", 16 | "command": "black .", 17 | "group": "build", 18 | "presentation": { 19 | "reveal": "always" 20 | } 21 | }, 22 | { 23 | "label": "Install requirements.txt dependencies", 24 | "type": "shell", 25 | "command": ".venv/bin/python -m pip install -r requirements.txt", 26 | "group": { 27 | "kind": "build", 28 | "isDefault": true 29 | }, 30 | "problemMatcher": [], 31 | "presentation": { 32 | "reveal": "always" 33 | } 34 | }, 35 | { 36 | "label": "Setup dev environment (.venv)", 37 | "type": "shell", 38 | "command": "scripts/setup", 39 | "group": "build", 40 | "problemMatcher": [], 41 | "presentation": { 42 | "reveal": "always" 43 | } 44 | }, 45 | { 46 | "label": "Run Home Assistant", 47 | "type": "shell", 48 | "command": "scripts/develop", 49 | "group": "build", 50 | "isBackground": true, 51 | "problemMatcher": [], 52 | "presentation": { 53 | "reveal": "always", 54 | "panel": "dedicated" 55 | } 56 | } 57 | ] 58 | } -------------------------------------------------------------------------------- /.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 | 5 | select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Change to repo root 6 | cd "$(dirname "$0")/.." 7 | 8 | # This should match the version used by HA core 9 | PY_VERSION=${PY_VERSION:-3.13.2} 10 | 11 | DEBIAN_FRONTEND=noninteractive 12 | 13 | echo "[setup] Updating apt cache and installing base packages..." 14 | apt-get update 15 | 16 | # Core build and tooling 17 | apt-get install -y --no-install-recommends \ 18 | ca-certificates \ 19 | curl \ 20 | git \ 21 | openssh-client \ 22 | build-essential \ 23 | pkg-config \ 24 | cmake \ 25 | autoconf 26 | 27 | # HA-like optional libs (safe to install; used by some integrations/tests) 28 | apt-get install -y --no-install-recommends \ 29 | bluez \ 30 | ffmpeg \ 31 | libudev-dev \ 32 | libavformat-dev \ 33 | libavcodec-dev \ 34 | libavdevice-dev \ 35 | libavutil-dev \ 36 | libgammu-dev \ 37 | libswscale-dev \ 38 | libswresample-dev \ 39 | libavfilter-dev \ 40 | libpcap-dev \ 41 | libturbojpeg0 \ 42 | libyaml-dev \ 43 | libxml2 44 | 45 | # Pre-create .ssh with safe permissions (useful for mounting keys) 46 | mkdir -p /root/.ssh 47 | chmod 700 /root/.ssh 48 | 49 | echo "[setup] Installing uv (Python toolchain manager)..." 50 | if ! command -v uv >/dev/null 2>&1; then 51 | # Install uv to ~/.local/bin/uv 52 | curl -fsSL https://astral.sh/uv/install.sh | sh 53 | fi 54 | 55 | # Ensure uv is on PATH 56 | export PATH="$HOME/.local/bin:$PATH" 57 | if [[ -d "$HOME/.cargo/bin" ]]; then 58 | export PATH="$HOME/.cargo/bin:$PATH" 59 | fi 60 | 61 | echo "[setup] Ensuring Python ${PY_VERSION} is available..." 62 | uv python install "${PY_VERSION}" 63 | 64 | RECREATE_VENV=0 65 | if [[ ! -x ".venv/bin/python" ]]; then 66 | RECREATE_VENV=1 67 | else 68 | # Get current Python version in the existing venv 69 | CURRENT_PY_VERSION=$(.venv/bin/python -c 'import platform; print(platform.python_version())' || echo "") 70 | if [[ "${CURRENT_PY_VERSION}" != "${PY_VERSION}" ]]; then 71 | RECREATE_VENV=1 72 | fi 73 | fi 74 | 75 | if [[ ${RECREATE_VENV} -eq 1 ]]; then 76 | echo "[setup] Creating virtual environment at .venv with Python ${PY_VERSION}..." 77 | uv venv --clear --python "${PY_VERSION}" .venv 78 | else 79 | echo "[setup] Reusing existing virtual environment (.venv) with Python ${CURRENT_PY_VERSION}." 80 | fi 81 | 82 | echo "[setup] Installing dependencies..." 83 | uv pip install --python .venv/bin/python -r requirements.txt 84 | 85 | echo "[setup] Cleaning apt cache..." 86 | apt-get clean 87 | rm -rf /var/lib/apt/lists/* 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | It comes with development environment in a container, easy to launch 54 | if you use Visual Studio Code. With this container you will have a stand alone 55 | Home Assistant instance running and already configured with the included 56 | [`configuration.yaml`](./config/configuration.yaml) 57 | file. 58 | 59 | ## License 60 | 61 | By contributing, you agree that your contributions will be licensed under its MIT License. 62 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Attention: This file is for development purpose only and can be ignored. 2 | 3 | # https://www.home-assistant.io/integrations/default_config/ 4 | default_config: 5 | 6 | # https://www.home-assistant.io/integrations/logger/ 7 | logger: 8 | default: info 9 | logs: 10 | custom_components.lightener: debug 11 | 12 | # Create a few fake lights take can be used in ha to create Lightener lights. 13 | light: 14 | - platform: template 15 | lights: 16 | on_off_light_1: 17 | friendly_name: "ON-OFF Light 1" 18 | turn_on: 19 | turn_off: 20 | on_off_light_2: 21 | friendly_name: "ON-OFF Light 2" 22 | turn_on: 23 | turn_off: 24 | living_room_ceiling: 25 | friendly_name: "Living Room Ceiling Lights" 26 | turn_on: 27 | turn_off: 28 | set_level: 29 | living_room_ceiling_leds: 30 | friendly_name: "Living Room Ceiling Leds" 31 | turn_on: 32 | turn_off: 33 | set_level: 34 | living_room_sofa_lamp: 35 | friendly_name: "Living Room Sofa Lamp" 36 | turn_on: 37 | turn_off: 38 | set_level: 39 | set_rgb: 40 | issue_97_ceiling: 41 | friendly_name: "Ceiling" 42 | turn_on: 43 | turn_off: 44 | issue_97_lamp: 45 | friendly_name: "Lamp" 46 | turn_on: 47 | turn_off: 48 | set_level: 49 | 50 | # This is the Lightener v1 configuration example. 51 | # Although *still* supported it has been replaced by UI configuration with v2. 52 | # 53 | # - platform: lightener 54 | # lights: 55 | # # This defines the entity id of your virtual light ("light.living_room_v1"). 56 | # living_room_v1: 57 | # ## The display name of your virtual light (optional). 58 | # friendly_name: "Living Room Lightened" 59 | # ## The list of the existing light entities that will be managed by the virtual light. 60 | # entities: 61 | # light.living_room_ceiling_leds: 62 | # 80: 100 # At 80% (room) the leds will reach 100% brightness. 63 | # light.living_room_sofa_lamp: 64 | # 20: 0 # At 20% (room) the sofa light is still off. 65 | # 60: 100 # At 60% (room) the sofa light reaches 100% brightness. 66 | # light.living_room_ceiling: 67 | # 60: 0 # At 60% (room) the main ceiling light is still off. 68 | # # 100: 100 ... no need for this as it is de default. 69 | 70 | # # As many virtual lights as you want can be added here. 71 | -------------------------------------------------------------------------------- /tests/components/lightener/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for testing.""" 2 | 3 | from collections.abc import Callable 4 | from uuid import uuid4 5 | 6 | import pytest 7 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers.entity_platform import async_get_platforms 10 | from homeassistant.setup import async_setup_component 11 | from pytest_homeassistant_custom_component.common import MockConfigEntry 12 | 13 | from custom_components.lightener.light import LightenerLight 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def auto_enable_custom_integrations(enable_custom_integrations): # pylint: disable=unused-argument 18 | """Enable custom integrations for all tests.""" 19 | yield 20 | 21 | 22 | @pytest.fixture(autouse=True) 23 | async def setup_test_lights(hass: HomeAssistant): 24 | """Register test lights used in tests.""" 25 | 26 | template_lights = { 27 | test: { 28 | "unique_id": test, 29 | "friendly_name": test, 30 | "turn_on": None, 31 | "turn_off": None, 32 | "set_level": None, 33 | } 34 | for test in ["test1", "test2", "test_onoff", "test_temp"] 35 | } 36 | 37 | # Make test_onoff support on/off only 38 | del template_lights["test_onoff"]["set_level"] 39 | 40 | # Makte test_temp support rgb and temperature 41 | template_lights["test_temp"]["set_rgb"] = None 42 | template_lights["test_temp"]["set_temperature"] = None 43 | 44 | await async_setup_component( 45 | hass, 46 | LIGHT_DOMAIN, 47 | {LIGHT_DOMAIN: [{"platform": "template", "lights": template_lights}]}, 48 | ) 49 | await hass.async_block_till_done() 50 | 51 | 52 | @pytest.fixture 53 | async def create_lightener( 54 | hass: HomeAssistant, 55 | ) -> Callable[[str, dict], LightenerLight]: 56 | """Create a function used to create Lightners.""" 57 | 58 | async def creator(name: str | None = None, config: dict | None = None) -> str: 59 | entry = MockConfigEntry( 60 | domain="lightener", 61 | unique_id=str(uuid4()), 62 | data={ 63 | "friendly_name": name or "Test", 64 | "entities": {"light.test1": {}}, 65 | } 66 | if config is None 67 | else config, 68 | ) 69 | entry.add_to_hass(hass) 70 | 71 | assert await hass.config_entries.async_setup(entry.entry_id) 72 | await hass.async_block_till_done() 73 | 74 | platform = async_get_platforms(hass, "lightener") 75 | return platform[0].entities["light.test"] 76 | 77 | return creator 78 | -------------------------------------------------------------------------------- /custom_components/lightener/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Vytvorte zariadenie Lightener", 6 | "description": "Zadajte názov zariadenia Lightener, ktoré vytvárate", 7 | "data": { 8 | "name": "Názov zariadenia" 9 | }, 10 | "data_description": { 11 | "name": "Účelom je jasne uviesť, ktorá skupina svetiel je týmto zariadením ovládaná. Zvyčajne je to názov oblasti alebo miestnosti, napríklad \"Obývačka\", ale môže to byť čokoľvek" 12 | } 13 | }, 14 | "lights": { 15 | "title": "Ovládané svetlá", 16 | "data": { 17 | "controlled_entities": "Vyberte svetelné entity, ktoré chcete ovládať týmto zariadením Lightener" 18 | } 19 | }, 20 | "light_configuration": { 21 | "title": "Konfigurovať \"{light_name}\"", 22 | "description": "Current brightness: {current_brightness}", 23 | "data": { 24 | "brightness": "Mapovanie jasu" 25 | }, 26 | "data_description": { 27 | "brightness": "Zoznam, ktorý mapuje jas tohto svetla, keď Lightener dosiahne určité úrovne jasu. Každý riadok má formát (Lightener jas %): (Kontrolovaný jas svetla %). Napríklad, \"60:100\" určuje, keď Lightener dosiahne jas 60 %, \"{light_name}\" dosiahne 100% jas" 28 | } 29 | } 30 | }, 31 | "error": { 32 | "controlled_entities_empty": "Vyberte aspoň jedno svetlo", 33 | "invalid_brightness": "Neplatná konfigurácia mapovania jasu \"{error_entry}\". Každý riadok musí mať formát \"(číslo od 1 do 100): (číslo od 0 do 100)\". Napríklad, \"40:100\"" 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "Ovládané svetlá", 40 | "data": { 41 | "controlled_entities": "Vyberte svetelné entity, ktoré chcete ovládať týmto zariadením Lightener" 42 | } 43 | }, 44 | "light_configuration": { 45 | "title": "Konfigurovať \"{light_name}\"", 46 | "description": "Current brightness: {current_brightness}", 47 | "data": { 48 | "brightness": "Mapovanie jasu" 49 | }, 50 | "data_description": { 51 | "brightness": "Zoznam, ktorý mapuje jas tohto svetla, keď Lightener dosiahne určité úrovne jasu. Každý riadok má formát (Jas Lightener %): (Kontrolovaný jas svetla %). Napríklad, \"60:100\" určuje, že keď Lightener dosiahne jas 60 %, \"{light_name}\" dosiahne 100% jas" 52 | } 53 | } 54 | }, 55 | "error": { 56 | "controlled_entities_empty": "Vyberte aspoň jedno svetlo", 57 | "invalid_brightness": "Neplatná konfigurácia mapovania jasu \"{error_entry}\". Každý riadok musí mať formát \"(číslo od 1 do 100): (číslo od 0 do 100)\". Napríklad, \"40:100\"" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/lightener/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Create a Lightener device", 6 | "description": "Please provide a name for the Lightener device you are creating", 7 | "data": { 8 | "name": "Device name" 9 | }, 10 | "data_description": { 11 | "name": "The purpose is to clearly indicate which group of lights is controlled by this device. Typically, it is an area or room name, such as \"Living Room,\" but it can be anything" 12 | } 13 | }, 14 | "lights": { 15 | "title": "Controlled lights", 16 | "data": { 17 | "controlled_entities": "Select the light entities to be controlled by this Lightener device" 18 | } 19 | }, 20 | "light_configuration": { 21 | "title": "Configure \"{light_name}\"", 22 | "description": "Current brightness: {current_brightness}", 23 | "data": { 24 | "brightness": "Brightness mapping" 25 | }, 26 | "data_description": { 27 | "brightness": "A list that maps the brightness of this light when the Lightener reaches certain brightness levels. Each line has the format (Lightener brightness %): (Controlled light brightness %). For example, \"60:100\" specifies that when the Lightener reaches 60% brightness, \"{light_name}\" will reach 100% brightness" 28 | } 29 | } 30 | }, 31 | "error": { 32 | "controlled_entities_empty": "Select at least one light", 33 | "invalid_brightness": "Invalid brightness mapping configuration \"{error_entry}\". Each line must have the format \"(number from 1 to 100): (number from 0 to 100)\". For example, \"40:100\"" 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "Controlled lights", 40 | "data": { 41 | "controlled_entities": "Select the light entities to be controlled by this Lightener device" 42 | } 43 | }, 44 | "light_configuration": { 45 | "title": "Configure \"{light_name}\"", 46 | "description": "Current brightness: {current_brightness}", 47 | "data": { 48 | "brightness": "Brightness mapping" 49 | }, 50 | "data_description": { 51 | "brightness": "A list that maps the brightness of this light when the Lightener reaches certain brightness levels. Each line has the format (Lightener brightness %): (Controlled light brightness %). For example, \"60:100\" specifies that when the Lightener reaches 60% brightness, \"{light_name}\" will reach 100% brightness" 52 | } 53 | } 54 | }, 55 | "error": { 56 | "controlled_entities_empty": "Select at least one light", 57 | "invalid_brightness": "Invalid brightness mapping configuration \"{error_entry}\". Each line must have the format \"(number from 1 to 100): (number from 0 to 100)\". For example, \"40:100\"" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/lightener/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Criar um dispositivo Lightener", 6 | "description": "Por favor, forneça um nome para o dispositivo Lightener que você está criando", 7 | "data": { 8 | "name": "Nome do dispositivo" 9 | }, 10 | "data_description": { 11 | "name": "O objetivo é indicar claramente qual grupo de luzes é controlado por este dispositivo. Normalmente, é o nome de uma área ou cômodo, como \"Sala de Estar\", mas pode ser qualquer coisa." 12 | } 13 | }, 14 | "lights": { 15 | "title": "Luzes controladas", 16 | "data": { 17 | "controlled_entities": "Selecione as entidades de luz a serem controladas por este dispositivo Lightener" 18 | } 19 | }, 20 | "light_configuration": { 21 | "title": "Configurar \"{light_name}\"", 22 | "description": "Brilho atual: {current_brightness}", 23 | "data": { 24 | "brightness": "Mapeamento de brilho" 25 | }, 26 | "data_description": { 27 | "brightness": "Uma lista que mapeia o brilho desta luz quando o Lightener atinge determinados níveis de brilho. Cada linha tem o formato (Brilho do Lightener %): (Brilho da luz controlada %). Por exemplo, \"60:100\" especifica que quando o Lightener atinge 60% de brilho, \"{light_name}\" atingirá 100% de brilho" 28 | } 29 | } 30 | }, 31 | "error": { 32 | "controlled_entities_empty": "Selecione pelo menos uma luz", 33 | "invalid_brightness": "Configuração de mapeamento de brilho inválida \"{error_entry}\". Cada linha deve ter o formato \"(número de 1 a 100): (número de 0 a 100)\". Por exemplo, \"40:100\"" 34 | } 35 | }, 36 | "options": { 37 | "step": { 38 | "init": { 39 | "title": "Luzes controladas", 40 | "data": { 41 | "controlled_entities": "Selecione as entidades de luz a serem controladas por este dispositivo Lightener" 42 | } 43 | }, 44 | "light_configuration": { 45 | "title": "Configurar \"{light_name}\"", 46 | "description": "Brilho atual: {current_brightness}", 47 | "data": { 48 | "brightness": "Mapeamento de brilho" 49 | }, 50 | "data_description": { 51 | "brightness": "Uma lista que mapeia o brilho desta luz quando o Lightener atinge determinados níveis de brilho. Cada linha tem o formato (Brilho do Lightener %): (Brilho da luz controlada %). Por exemplo, \"60:100\" especifica que quando o Lightener atinge 60% de brilho, \"{light_name}\" atingirá 100% de brilho." 52 | } 53 | } 54 | }, 55 | "error": { 56 | "controlled_entities_empty": "Selecione pelo menos uma luz", 57 | "invalid_brightness": "Configuração de mapeamento de brilho inválida \"{error_entry}\". Cada linha deve ter o formato \"(número de 1 a 100): (número de 0 a 100)\". Por exemplo, \"40:100\"" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/lightener/__init__.py: -------------------------------------------------------------------------------- 1 | """Lightener Integration.""" 2 | 3 | import logging 4 | from types import MappingProxyType 5 | from typing import Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import Platform 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.device_registry import DeviceEntry 11 | 12 | from .config_flow import LightenerConfigFlow 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | PLATFORMS = [Platform.LIGHT] 17 | 18 | 19 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 20 | """Set up platform from a config entry.""" 21 | 22 | hass.async_create_task( 23 | hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 24 | ) 25 | 26 | return True 27 | 28 | 29 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 30 | """Unload a config entry.""" 31 | 32 | # Forward the unloading of the entry to the platform. 33 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 34 | 35 | return unload_ok 36 | 37 | 38 | async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 39 | """Update old versions of the configuration to the current format.""" 40 | 41 | version = config_entry.version 42 | data = config_entry.data 43 | 44 | # Lightener 1.x didn't have config entries, just manual configuration.yaml. We consider this the no-version option. 45 | if version is None or version == 1: 46 | new_data = await async_migrate_data(data, version) 47 | 48 | hass.config_entries.async_update_entry(config_entry, data=new_data, version=2) 49 | 50 | return True 51 | 52 | if config_entry.version == LightenerConfigFlow.VERSION: 53 | return True 54 | 55 | _LOGGER.error('Unknow configuration version "%i"', version) 56 | return False 57 | 58 | 59 | async def async_migrate_data( 60 | data: MappingProxyType[str, Any], version: int = None 61 | ) -> MappingProxyType[str, Any]: 62 | """Update data from old versions of the configuration to the current format.""" 63 | 64 | # Lightener 1.x didn't have config entries, just manual configuration.yaml. We consider this the no-version option. 65 | if version is None or version == 1: 66 | new_data = { 67 | "entities": {}, 68 | } 69 | 70 | if data.get("friendly_name") is not None: 71 | new_data["friendly_name"] = data["friendly_name"] 72 | 73 | for entity, brightness in data.get("entities", {}).items(): 74 | new_data.get("entities")[entity] = {"brightness": brightness} 75 | 76 | return new_data 77 | 78 | # Otherwise return a copy of the data. 79 | return dict(data) 80 | 81 | 82 | async def async_remove_config_entry_device( 83 | _hass: HomeAssistant, _config_entry: ConfigEntry, _device_entry: DeviceEntry 84 | ) -> bool: 85 | """Remove a config entry from a device.""" 86 | 87 | return True 88 | -------------------------------------------------------------------------------- /images/lightener.svg: -------------------------------------------------------------------------------- 1 | 2 | 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lightener 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![hacs][hacsbadge]][hacs] 5 | 6 | Lightener is a Home Assistant integration used to create virtual lights that can control a group of lights. It offers the added benefit of controlling the state (on/off) and brightness level of each light independently. 7 | 8 | ## Example Use Case 9 | 10 | Suppose you have the following lights in your living room, all available as independent entities in Home Assistant: 11 | 12 | - Main ceiling light 13 | - LED strip around the ceiling 14 | - Sofa lamp 15 | 16 | You want to **control all lights at once**, such as having a single switch on the wall to turn all lights on/off. It's an easy task, simply create a simple automation for that. 17 | 18 | Now, you want something magical: the ability to **control the brightness of the whole room at once**. You don't want all three lights to have the same brightness level (e.g., all at 30%). Instead, you want each light to gradually match the room's brightness level. For example: 19 | 20 | - Room brightness 0% to 40%: the ceiling LEDs gradually reach 50% of their brightness. 21 | - Room brightness from 20%: the sofa light gradually joins in. 22 | - Room brightness 60%: the sofa light is at 100%, and the main light joins in gradually. 23 | - Room brightness 80%: the ceiling LEDs are at 100% (sofa light remains at 100%). 24 | - Room brightness 100%: the main light is at 100% (sofa light and LEDs still at 100%). 25 | 26 | Here's a screencast demonstrating the above in action: 27 | 28 | ![Screencast of the example](https://github.com/fredck/lightener/blob/master/images/lightener-example.gif?raw=true "A screencast of the above in action") 29 | 30 | Lightener makes this magic possible. 31 | 32 | ## Installation 33 | 34 | ### Using HACS (recommended) 35 | 36 | Simply search for `Lightener` in HACS and easily install it. 37 | 38 | ### Manual 39 | 40 | Copy the `custom_components/lightener` directory from this repository to `config/custom_components/lightener` in your Home Assistant installation. 41 | 42 | ## Creating Lightener Lights 43 | 44 | After planning how you want your lights to work, it's time to create Lightener (virtual) lights that will control them. 45 | 46 | To start, follow these steps in your Home Assistant installation: 47 | 48 | 1. Go to "Settings > Devices & Services" to access the "Integrations" page. 49 | 2. Click the "+ Add Integration" button. 50 | 3. Search for and select the "Lightener" integration. 51 | 52 | This will initiate the configuration flow for a new Lightener light, which includes several steps: 53 | 54 | 1. Give a name to your Lightener light. The name should make it easy for users to understand which lights are being controlled. For example, if you want to control several lights in the living room, you can name it "Living Room". 55 | 2. Select the lights that you want to control. 56 | 3. Configure each of the selected lights. 57 | 58 | ### Light Configuration 59 | 60 | For each light to be controlled by a Lightener light, you must specify the mapping between the brightness intensity of both the controlling and the controlled lights. This is done by providing a list where each line defines a mapping step. 61 | 62 | For example, in the previously presented use case, the configuration would be as follows (without the parentheses): 63 | 64 | - Main ceiling light 65 | - **60: 0** (At 60% room brightness, the main ceiling light is still off) 66 | - (100: 100 ... no need for this as it is the default for 100% room brightness) 67 | - Ceiling LEDs 68 | - **80: 100** (At 80% room brightness, the LEDs will reach 100% brightness) 69 | - Sofa lamp 70 | - **20: 0** (At 20% room brightness, the sofa light is still off) 71 | - **60: 100** (At 60% room brightness, the sofa light reaches 100% brightness) 72 | 73 | Note that we didn't have to define `40:50` for the LEDs, as the use case exemplifies. This is because the integration will automatically calculate the proper brightness for each step of the room brightness level. Since we configured `80:100`, at 40% room brightness, the LEDs will be at 50%, just like they'll be at 25% when the room reaches 20%, and so on. 74 | 75 | Once the configuration is confirmed, a new device becomes available, which can be used in the UI or in automations to control all the lights in the room at once. 76 | 77 | One light to rule them all! 78 | 79 | ### Support for On/Off Lights 80 | 81 | Lightener supports controlling so-called "On/Off Lights." These are lights that cannot be dimmed but can only be turned on and off. 82 | 83 | The configuration of On/Off Lights is similar to dimmable lights. The difference is that if the light is set to zero, it will be off. Any other "brightness" level will simply turn the light on. 84 | 85 | For example, if an On/Off Light is configured with "20:0, 50:30, 100:0," it will be set to off when the Lightener is in the brightness range of 0-20% or when it reaches 100%. Between 21-99%, the light will be on. 86 | 87 | ### Tips 88 | 89 | - A light doesn't have to always go to 100%. If you don't want it to exceed, for example, 80%, you can configure it with `100:80`. 90 | - Brightness can both increase and decrease. For example, `60:100` + `100:20` will make a light be at 100% brightness when the room is at 60%, and then decrease its brightness until 20% when the room is at 100%. 91 | - A light can be turned off at any point by setting it to zero. For example, `30:100` + `60:0` will make it go to 100% when the room is at 30% and gradually turn off until the room reaches 60% (and then back to 100% at 100% because of the following point). 92 | - Lights will automatically have a `100:100` configuration, so if you need to change the default behavior at 100%, you can adjust it accordingly. 93 | 94 | Have fun! 95 | 96 | [hacs]: https://github.com/hacs/integration 97 | [hacsbadge]: https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=for-the-badge 98 | 99 | [releases-shield]: https://img.shields.io/github/release/fredck/lightener.svg?style=for-the-badge 100 | [releases]: https://github.com/fredck/lightener/releases 101 | -------------------------------------------------------------------------------- /tests/components/lightener/test_init.py: -------------------------------------------------------------------------------- 1 | """Tests for __init__.""" 2 | 3 | import logging 4 | from unittest.mock import patch 5 | 6 | from homeassistant.config_entries import ConfigEntry, ConfigEntryState 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers import device_registry as dr 9 | from homeassistant.helpers.entity_registry import ( 10 | async_get as async_get_entity_registry, 11 | ) 12 | from homeassistant.setup import async_setup_component 13 | from pytest_homeassistant_custom_component.common import MockConfigEntry 14 | 15 | from custom_components.lightener import ( 16 | async_migrate_entry, 17 | async_unload_entry, 18 | ) 19 | from custom_components.lightener.config_flow import LightenerConfigFlow 20 | from custom_components.lightener.const import DOMAIN 21 | 22 | 23 | async def test_async_setup_entry(hass): 24 | """Test setting up Lightener successfully.""" 25 | config_entry = MockConfigEntry( 26 | domain="lightener", 27 | data={ 28 | "friendly_name": "Test", 29 | "entities": { 30 | "light.test1": {}, 31 | }, 32 | }, 33 | ) 34 | config_entry.add_to_hass(hass) 35 | 36 | assert await hass.config_entries.async_setup(config_entry.entry_id) 37 | await hass.async_block_till_done() 38 | assert "lightener.light" in hass.config.components 39 | 40 | 41 | @patch("custom_components.lightener.async_unload_entry", wraps=async_unload_entry) 42 | async def test_async_unload_entry(mock_unload, hass): 43 | """Test setting up Lightener successfully.""" 44 | config_entry = MockConfigEntry( 45 | domain="lightener", 46 | data={ 47 | "friendly_name": "Test", 48 | "entities": { 49 | "light.test1": {}, 50 | }, 51 | }, 52 | ) 53 | config_entry.add_to_hass(hass) 54 | await hass.config_entries.async_setup(config_entry.entry_id) 55 | await hass.async_block_till_done() 56 | assert config_entry.state is ConfigEntryState.LOADED 57 | assert "light.test" in hass.states.async_entity_ids() 58 | 59 | assert await hass.config_entries.async_remove(config_entry.entry_id) 60 | await hass.async_block_till_done() 61 | assert config_entry.state is ConfigEntryState.NOT_LOADED 62 | assert "light.test" not in hass.states.async_entity_ids() 63 | 64 | # Ensure that the Lightener unload implementation was called. 65 | mock_unload.assert_called_once() 66 | assert mock_unload.return_value 67 | 68 | 69 | async def test_migrate_entry_current(hass: HomeAssistant) -> None: 70 | """Test is the migration does nothing for an up-to-date configuration.""" 71 | 72 | config_entry = ConfigEntry( 73 | version=LightenerConfigFlow.VERSION, 74 | minor_version=LightenerConfigFlow.VERSION, 75 | title="lightener", 76 | domain=DOMAIN, 77 | data={}, 78 | source="user", 79 | unique_id=None, 80 | options=None, 81 | discovery_keys=[], 82 | subentries_data={}, 83 | ) 84 | 85 | data = config_entry.data 86 | 87 | assert await async_migrate_entry(hass, config_entry) is True 88 | 89 | assert config_entry.data is data 90 | 91 | 92 | async def test_migrate_entry_v1(hass: HomeAssistant) -> None: 93 | """Test is the migration does nothing for an up-to-date configuration.""" 94 | 95 | config_v1 = { 96 | "friendly_name": "Test", 97 | "entities": { 98 | "light.test1": { 99 | "10": "20", 100 | "30": "40", 101 | }, 102 | "light.test2": { 103 | "50": "60", 104 | "70": "80", 105 | }, 106 | }, 107 | } 108 | 109 | config_entry = ConfigEntry( 110 | version=1, 111 | minor_version=1, 112 | title="lightener", 113 | domain=DOMAIN, 114 | data=config_v1, 115 | source="user", 116 | unique_id=None, 117 | options=None, 118 | discovery_keys=[], 119 | subentries_data={}, 120 | ) 121 | 122 | with patch.object(hass.config_entries, "async_update_entry") as update_mock: 123 | assert await async_migrate_entry(hass, config_entry) is True 124 | 125 | assert update_mock.call_count == 1 126 | assert update_mock.call_args.kwargs.get("data") == { 127 | "friendly_name": "Test", 128 | "entities": { 129 | "light.test1": {"brightness": {"10": "20", "30": "40"}}, 130 | "light.test2": {"brightness": {"50": "60", "70": "80"}}, 131 | }, 132 | } 133 | 134 | 135 | async def test_migrate_unkown_version(hass: HomeAssistant) -> None: 136 | """Test is the migration does nothing for an up-to-date configuration.""" 137 | 138 | config_entry = ConfigEntry( 139 | version=1000, 140 | minor_version=1000, 141 | title="lightener", 142 | domain=DOMAIN, 143 | data={}, 144 | source="user", 145 | unique_id=None, 146 | options=None, 147 | discovery_keys=[], 148 | subentries_data={}, 149 | ) 150 | 151 | with patch.object(logging.Logger, "error") as mock: 152 | assert await async_migrate_entry(hass, config_entry) is False 153 | 154 | mock.assert_called_once_with('Unknow configuration version "%i"', 1000) 155 | 156 | 157 | async def test_remove_device( 158 | hass: HomeAssistant, hass_ws_client, create_lightener 159 | ) -> None: 160 | """Ensure HA can remove the Lightener device.""" 161 | 162 | # Create a Lightener via the helper so a device and entity are registered. 163 | lightener = await create_lightener() 164 | 165 | # Find the created entity and its device id. 166 | er = async_get_entity_registry(hass) 167 | entity_entry = er.async_get(lightener.entity_id) 168 | assert entity_entry is not None 169 | assert entity_entry.device_id is not None 170 | device_id = entity_entry.device_id 171 | assert entity_entry.config_entry_id is not None 172 | config_entry_id = entity_entry.config_entry_id 173 | 174 | # Ensure the config component is set up so it registers the device_registry websocket commands. 175 | assert await async_setup_component(hass, "config", {}) 176 | await hass.async_block_till_done() 177 | 178 | # Call the websocket API to remove the config entry from the device. 179 | ws = await hass_ws_client(hass) 180 | ws_result = await ws.remove_device(device_id, config_entry_id) 181 | 182 | # It should succeed and return a result payload. 183 | assert ws_result["type"] == "result" 184 | assert ws_result["success"] is True 185 | 186 | # And the device should no longer reference this config entry. 187 | dev_reg = dr.async_get(hass) 188 | device_entry = dev_reg.async_get(device_id) 189 | if device_entry is not None: 190 | assert config_entry_id not in device_entry.config_entries 191 | -------------------------------------------------------------------------------- /tests/components/lightener/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Tests for config_flow.""" 2 | 3 | from typing import Any 4 | from uuid import uuid4 5 | 6 | import voluptuous as vol 7 | from homeassistant import config_entries 8 | from homeassistant.const import CONF_BRIGHTNESS, CONF_ENTITIES, CONF_FRIENDLY_NAME 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.data_entry_flow import FlowResult 11 | from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry 12 | from pytest_homeassistant_custom_component.common import MockConfigEntry 13 | 14 | from custom_components.lightener import const 15 | from custom_components.lightener.config_flow import LightenerConfigFlow 16 | 17 | 18 | async def test_config_flow_steps(hass: HomeAssistant) -> None: 19 | """Test if the full config flow works.""" 20 | 21 | result = await hass.config_entries.flow.async_init( 22 | const.DOMAIN, context={"source": config_entries.SOURCE_USER} 23 | ) 24 | 25 | assert result["type"] == "form" 26 | assert result["step_id"] == "user" 27 | assert result["last_step"] is False 28 | 29 | assert get_required(result, "name") is True 30 | 31 | result = await hass.config_entries.flow.async_configure( 32 | result["flow_id"], user_input={"name": "Test Name"} 33 | ) 34 | 35 | assert result["type"] == "form" 36 | assert result["step_id"] == "lights" 37 | assert result["last_step"] is False 38 | 39 | assert get_required(result, "controlled_entities") is True 40 | 41 | result = await hass.config_entries.flow.async_configure( 42 | result["flow_id"], 43 | user_input={"controlled_entities": ["light.test1"]}, 44 | ) 45 | 46 | assert result["type"] == "form" 47 | assert result["step_id"] == "light_configuration" 48 | assert result["last_step"] is True 49 | assert result["description_placeholders"] == { 50 | "light_name": "test1", 51 | "current_brightness": "off", 52 | } 53 | 54 | assert get_required(result, "brightness") is False 55 | 56 | result = await hass.config_entries.flow.async_configure( 57 | result["flow_id"], 58 | user_input={"brightness": "10:20"}, 59 | ) 60 | 61 | assert result["type"] == "create_entry" 62 | assert result["title"] == "Test Name" 63 | assert result["data"] == { 64 | CONF_FRIENDLY_NAME: "Test Name", 65 | CONF_ENTITIES: {"light.test1": {CONF_BRIGHTNESS: {"10": "20"}}}, 66 | } 67 | 68 | 69 | async def test_options_flow_steps(hass: HomeAssistant) -> None: 70 | """Test if the full options flow works.""" 71 | 72 | entry = MockConfigEntry( 73 | domain="lightener", 74 | version=LightenerConfigFlow.VERSION, 75 | unique_id=str(uuid4()), 76 | data={ 77 | CONF_ENTITIES: { 78 | "light.test1": {CONF_BRIGHTNESS: {"10": "20"}}, 79 | "light.test2": {CONF_BRIGHTNESS: {"30": "40"}}, 80 | } 81 | }, 82 | ) 83 | entry.add_to_hass(hass) 84 | 85 | result = await hass.config_entries.options.async_init(entry.entry_id) 86 | 87 | assert result["type"] == "form" 88 | assert result["step_id"] == "init" 89 | assert result["last_step"] is False 90 | 91 | assert get_default(result, "controlled_entities") == ["light.test1", "light.test2"] 92 | 93 | result = await hass.config_entries.options.async_configure( 94 | result["flow_id"], 95 | user_input={"controlled_entities": ["light.test1"]}, 96 | ) 97 | 98 | assert result["type"] == "form" 99 | assert result["step_id"] == "light_configuration" 100 | assert result["last_step"] is True 101 | 102 | assert get_suggested(result, "brightness") == "10: 20" 103 | 104 | result = await hass.config_entries.options.async_configure( 105 | result["flow_id"], 106 | user_input={"brightness": "50:60"}, 107 | ) 108 | 109 | assert result["type"] == "create_entry" 110 | assert result["title"] == "" 111 | assert result["data"] == {} 112 | 113 | assert dict(entry.data) == { 114 | CONF_ENTITIES: {"light.test1": {CONF_BRIGHTNESS: {"50": "60"}}} 115 | } 116 | 117 | assert entry.options == {} 118 | 119 | 120 | async def test_step_lights_no_lightener(hass: HomeAssistant) -> None: 121 | """Test if the list of lights to select doesn't include the lightener being configured.""" 122 | 123 | entry = MockConfigEntry( 124 | domain="lightener", 125 | unique_id=str(uuid4()), 126 | data={CONF_ENTITIES: {"light.test1": {CONF_BRIGHTNESS: {"10": "20"}}}}, 127 | ) 128 | entry.add_to_hass(hass) 129 | 130 | entity_registry = async_get_entity_registry(hass) 131 | 132 | entity_registry.async_get_or_create( 133 | domain="light", 134 | platform="lightener", 135 | unique_id=str(uuid4()), 136 | config_entry=entry, 137 | suggested_object_id="test_lightener", 138 | ) 139 | 140 | result = await hass.config_entries.options.async_init(entry.entry_id) 141 | 142 | assert get_default(result, "controlled_entities") == ["light.test1"] 143 | 144 | 145 | async def test_step_lights_error_no_selection(hass: HomeAssistant) -> None: 146 | """Test if the list of lights to select doesn't include the lightener being configured.""" 147 | 148 | result = await hass.config_entries.flow.async_init( 149 | const.DOMAIN, context={"source": config_entries.SOURCE_USER} 150 | ) 151 | result = await hass.config_entries.flow.async_configure( 152 | result["flow_id"], user_input={"name": "Test Name"} 153 | ) 154 | 155 | result = await hass.config_entries.flow.async_configure( 156 | result["flow_id"], 157 | user_input={"controlled_entities": []}, 158 | ) 159 | 160 | assert result["step_id"] == "lights" 161 | assert result["errors"]["controlled_entities"] == "controlled_entities_empty" 162 | 163 | 164 | async def test_step_light_configuration_multiple_lights(hass: HomeAssistant) -> None: 165 | """Test if the flow works when multiple lights are selected.""" 166 | 167 | result = await hass.config_entries.flow.async_init( 168 | const.DOMAIN, context={"source": config_entries.SOURCE_USER} 169 | ) 170 | result = await hass.config_entries.flow.async_configure( 171 | result["flow_id"], user_input={"name": "Test Name"} 172 | ) 173 | 174 | result = await hass.config_entries.flow.async_configure( 175 | result["flow_id"], 176 | user_input={"controlled_entities": ["light.test1", "light.test2"]}, 177 | ) 178 | 179 | assert result["step_id"] == "light_configuration" 180 | assert result["last_step"] is False 181 | 182 | result = await hass.config_entries.flow.async_configure( 183 | result["flow_id"], 184 | user_input={"brightness": "50:60"}, 185 | ) 186 | 187 | assert result["step_id"] == "light_configuration" 188 | assert result["last_step"] is True 189 | 190 | 191 | async def test_step_light_configuration_brightness_validation( 192 | hass: HomeAssistant, 193 | ) -> None: 194 | """Test the input validation of the brightness field.""" 195 | 196 | async def assert_value(must_pass, value, error_value=None): 197 | result = await hass.config_entries.flow.async_init( 198 | const.DOMAIN, context={"source": config_entries.SOURCE_USER} 199 | ) 200 | result = await hass.config_entries.flow.async_configure( 201 | result["flow_id"], user_input={"name": "Test Name"} 202 | ) 203 | result = await hass.config_entries.flow.async_configure( 204 | result["flow_id"], 205 | user_input={"controlled_entities": ["light.test1"]}, 206 | ) 207 | 208 | result = await hass.config_entries.flow.async_configure( 209 | result["flow_id"], 210 | user_input={"brightness": value}, 211 | ) 212 | 213 | if must_pass is True: 214 | assert ( 215 | result["type"] == "create_entry" 216 | ), f"{value} => '{result['description_placeholders']['error_entry']}'" 217 | else: 218 | assert result["errors"]["brightness"] == "invalid_brightness", value 219 | assert ( 220 | result["description_placeholders"]["error_entry"] == value 221 | or error_value 222 | ), value 223 | 224 | # Wrong format 225 | await assert_value(False, "50:60x") 226 | await assert_value(False, "x50:60") 227 | await assert_value(False, "x50:60") 228 | await assert_value(False, "50-60") 229 | await assert_value(False, "50=60x") 230 | await assert_value(False, "50 60x") 231 | await assert_value(False, "-50:-60") 232 | await assert_value(False, "bla") 233 | await assert_value(False, "10: 20\n50:60x", "50:60x") 234 | 235 | # Wrong values 236 | await assert_value(False, "0:50") 237 | await assert_value(False, "101:50") 238 | await assert_value(False, "50:101") 239 | 240 | # Good ones 241 | await assert_value(True, "1:0") # Lowest values 242 | await assert_value(True, "100:100") # Highest values 243 | await assert_value(True, "50:60") 244 | await assert_value(True, " 50:60") 245 | await assert_value(True, " 50 : 60 ") 246 | await assert_value(True, "50:60 ") 247 | await assert_value(True, "50 : 60") 248 | await assert_value(True, "50: 60") 249 | await assert_value(True, "50 :60") 250 | await assert_value(True, "50 :60\n 10: 20 \n30:40") 251 | await assert_value(True, "") 252 | 253 | 254 | def get_default(form: FlowResult, key: str) -> Any: 255 | """Get default value for key in voluptuous schema.""" 256 | 257 | for schema_key in form["data_schema"].schema: 258 | if schema_key == key: 259 | if schema_key.default != vol.UNDEFINED: 260 | return schema_key.default() 261 | return None 262 | 263 | raise KeyError(f"Key '{key}' not found") 264 | 265 | 266 | def get_suggested(form: FlowResult, key: str) -> Any: 267 | """Get default value for key in voluptuous schema.""" 268 | 269 | for schema_key in form["data_schema"].schema: 270 | if schema_key == key: 271 | if ( 272 | schema_key.description is None 273 | or "suggested_value" not in schema_key.description 274 | ): 275 | return None 276 | return schema_key.description["suggested_value"] 277 | 278 | raise KeyError(f"Key '{key}' not found") 279 | 280 | 281 | def get_required(form: FlowResult, key: str) -> Any: 282 | """Get default value for key in voluptuous schema.""" 283 | 284 | for schema_key in form["data_schema"].schema: 285 | if schema_key == key: 286 | return isinstance(schema_key, vol.Required) 287 | 288 | raise KeyError(f"Key '{key}' not found") 289 | -------------------------------------------------------------------------------- /custom_components/lightener/config_flow.py: -------------------------------------------------------------------------------- 1 | """The config flow for Lightener.""" 2 | 3 | import re 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | from homeassistant import config_entries 8 | from homeassistant.const import CONF_BRIGHTNESS, CONF_ENTITIES, CONF_FRIENDLY_NAME 9 | from homeassistant.core import callback 10 | from homeassistant.data_entry_flow import FlowHandler, FlowResult 11 | from homeassistant.helpers.entity_registry import ( 12 | async_entries_for_config_entry, 13 | async_get, 14 | ) 15 | from homeassistant.helpers.selector import selector 16 | from homeassistant.util.color import brightness_to_value 17 | 18 | from .const import DOMAIN, TYPE_DIMMABLE, TYPE_ONOFF 19 | from .util import get_light_type 20 | 21 | 22 | class LightenerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 23 | """Lightener config flow.""" 24 | 25 | # The schema version of the entries that it creates. 26 | # Home Assistant will call the migrate method if the version changes. 27 | VERSION = 2 28 | 29 | def __init__(self) -> None: 30 | """Initialize options flow.""" 31 | self.lightener_flow = LightenerFlow(self, steps={"name": "user"}) 32 | super().__init__() 33 | 34 | async def async_step_user(self, user_input: dict[str, Any] | None = None): 35 | """Configure the lighener device name.""" 36 | 37 | return await self.lightener_flow.async_step_name(user_input) 38 | 39 | async def async_step_lights( 40 | self, user_input: dict[str, Any] | None = None 41 | ) -> FlowResult: 42 | """Manage the selection of the lights controlled by the Lighetner light.""" 43 | return await self.lightener_flow.async_step_lights(user_input) 44 | 45 | async def async_step_light_configuration( 46 | self, user_input: dict[str, Any] | None = None 47 | ) -> FlowResult: 48 | """Manage the configuration for each controlled light.""" 49 | return await self.lightener_flow.async_step_light_configuration(user_input) 50 | 51 | @staticmethod 52 | @callback 53 | def async_get_options_flow( 54 | config_entry: config_entries.ConfigEntry, 55 | ) -> config_entries.OptionsFlow: 56 | """Create the options flow.""" 57 | 58 | return LightenerOptionsFlow(config_entry) 59 | 60 | 61 | class LightenerOptionsFlow(config_entries.OptionsFlow): 62 | """The options flow handler for Lightener.""" 63 | 64 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None: 65 | """Initialize options flow.""" 66 | self.lightener_flow = LightenerFlow( 67 | self, steps={"lights": "init"}, config_entry=config_entry 68 | ) 69 | super().__init__() 70 | 71 | async def async_step_init( 72 | self, user_input: dict[str, Any] | None = None 73 | ) -> FlowResult: 74 | """Manage the selection of the lights controlled by the Lighetner light.""" 75 | return await self.lightener_flow.async_step_lights(user_input) 76 | 77 | async def async_step_light_configuration( 78 | self, user_input: dict[str, Any] | None = None 79 | ) -> FlowResult: 80 | """Manage the configuration for each controlled light.""" 81 | return await self.lightener_flow.async_step_light_configuration(user_input) 82 | 83 | 84 | class LightenerFlow: 85 | """Handle steps for both the config and the options flow.""" 86 | 87 | def __init__( 88 | self, 89 | flow_handler: FlowHandler, 90 | steps: dict, 91 | config_entry: config_entries.ConfigEntry | None = None, 92 | ) -> None: 93 | """Initialize the LightenerFlow.""" 94 | 95 | self.flow_handler = flow_handler 96 | self.config_entry = config_entry 97 | self.data = {} if config_entry is None else config_entry.data.copy() 98 | self.local_data = {} 99 | self.steps = steps 100 | 101 | async def async_step_name(self, user_input: dict[str, Any] | None = None): 102 | """Configure the lighener device name.""" 103 | 104 | errors = {} 105 | 106 | if user_input is not None: 107 | name = user_input["name"] 108 | 109 | self.data[CONF_FRIENDLY_NAME] = name 110 | 111 | return await self.async_step_lights() 112 | 113 | data_schema = { 114 | vol.Required("name"): str, 115 | } 116 | 117 | return self.flow_handler.async_show_form( 118 | step_id=self.steps.get("name", "name"), 119 | last_step=False, 120 | data_schema=vol.Schema(data_schema), 121 | errors=errors, 122 | ) 123 | 124 | async def async_step_lights( 125 | self, user_input: dict[str, Any] | None = None 126 | ) -> FlowResult: 127 | """Manage the selection of the lights controlled by the Lighetner light.""" 128 | 129 | errors = {} 130 | 131 | lightener_entities = [] 132 | controlled_entities = [] 133 | 134 | if self.config_entry is not None: 135 | # Create a list with the ids of the Lightener entities we're configuring. 136 | # Most likely we'll have a single item in the list. 137 | entity_registry = async_get(self.flow_handler.hass) 138 | lightener_entities = async_entries_for_config_entry( 139 | entity_registry, self.config_entry.entry_id 140 | ) 141 | lightener_entities = [e.entity_id for e in lightener_entities] 142 | 143 | # Load the previously configured list of entities controlled by this Lightener. 144 | controlled_entities = list( 145 | self.config_entry.data.get(CONF_ENTITIES, {}).keys() 146 | ) 147 | 148 | if user_input is not None: 149 | controlled_entities = self.local_data[ 150 | "controlled_entities" 151 | ] = user_input.get("controlled_entities") 152 | 153 | if not controlled_entities: 154 | errors["controlled_entities"] = "controlled_entities_empty" 155 | else: 156 | entities = self.data[CONF_ENTITIES] = {} 157 | 158 | for entity in controlled_entities: 159 | entities[entity] = {} 160 | 161 | return await self.async_step_light_configuration() 162 | 163 | return self.flow_handler.async_show_form( 164 | step_id=self.steps.get("lights", "lights"), 165 | last_step=False, 166 | data_schema=vol.Schema( 167 | { 168 | vol.Required( 169 | "controlled_entities", default=controlled_entities 170 | ): selector( 171 | { 172 | "entity": { 173 | "multiple": True, 174 | "filter": {"domain": "light"}, 175 | "exclude_entities": lightener_entities, 176 | } 177 | } 178 | ) 179 | } 180 | ), 181 | errors=errors, 182 | ) 183 | 184 | async def async_step_light_configuration( 185 | self, user_input: dict[str, Any] | None = None 186 | ) -> FlowResult: 187 | """Manage the configuration for each controlled light.""" 188 | 189 | brightness = "" 190 | placeholders = {} 191 | errors = {} 192 | 193 | controlled_entities = self.local_data.get("controlled_entities") 194 | 195 | if user_input is not None: 196 | brightness = {} 197 | 198 | for entry in user_input.get("brightness", "").splitlines(): 199 | match = re.fullmatch(r"^\s*(\d+)\s*:\s*(\d+)\s*$", entry) 200 | 201 | if match is not None: 202 | left = int(match.group(1)) 203 | right = int(match.group(2)) 204 | 205 | if left >= 1 and left <= 100 and right <= 100: 206 | brightness[str(left)] = str(right) 207 | continue 208 | 209 | errors["brightness"] = "invalid_brightness" 210 | placeholders["error_entry"] = entry 211 | break 212 | 213 | if len(errors) == 0: 214 | entities: dict = self.data.get(CONF_ENTITIES) 215 | entities.get(self.local_data.get("current_light"))[ 216 | CONF_BRIGHTNESS 217 | ] = brightness 218 | 219 | if len(controlled_entities): 220 | return await self.async_step_light_configuration() 221 | 222 | return await self.async_save_data() 223 | else: 224 | light = self.local_data["current_light"] = controlled_entities.pop(0) 225 | 226 | light = self.local_data["current_light"] 227 | state = self.flow_handler.hass.states.get(light) 228 | placeholders["light_name"] = state.name 229 | 230 | light_type = get_light_type(hass=self.flow_handler.hass, entity_id=light) 231 | 232 | # Placeholder for the current brightness value (in percentage). 233 | current_brightness = ( 234 | state.attributes.get("brightness", 0) 235 | if light_type == TYPE_DIMMABLE 236 | else 255 237 | if light_type == TYPE_ONOFF and state.state == "on" 238 | else 0 239 | ) 240 | 241 | placeholders["current_brightness"] = ( 242 | f"{round(brightness_to_value((1, 100), current_brightness))}%" 243 | if current_brightness 244 | else "?" 245 | if state.state == "on" 246 | else "off" 247 | ) 248 | 249 | if user_input is None: 250 | # Load the previously configured data. 251 | if self.config_entry is not None: 252 | brightness = ( 253 | self.config_entry.data.get(CONF_ENTITIES, {}) 254 | .get(light, {}) 255 | .get(CONF_BRIGHTNESS, {}) 256 | ) 257 | 258 | brightness = "\n".join( 259 | [(str(key) + ": " + str(brightness[key])) for key in brightness] 260 | ) 261 | else: 262 | brightness = user_input["brightness"] 263 | 264 | schema = { 265 | vol.Optional( 266 | "brightness", description={"suggested_value": brightness} 267 | ): selector({"template": {}}) 268 | } 269 | 270 | return self.flow_handler.async_show_form( 271 | step_id=self.steps.get("light_configuration", "light_configuration"), 272 | last_step=len(controlled_entities) == 0, 273 | data_schema=vol.Schema(schema), 274 | description_placeholders=placeholders, 275 | errors=errors, 276 | ) 277 | 278 | async def async_save_data(self) -> FlowResult: 279 | """Save the configured data.""" 280 | 281 | # We don't save it into the "options" key but always in "config", 282 | # no matter if the user called the config or the options flow. 283 | 284 | # If in a config flow, create the config entry. 285 | if self.config_entry is None: 286 | return self.flow_handler.async_create_entry( 287 | title=self.data.get(CONF_FRIENDLY_NAME), data=self.data 288 | ) 289 | 290 | # In an options flow, update the config entry. 291 | self.flow_handler.hass.config_entries.async_update_entry( 292 | self.config_entry, data=self.data, options=self.config_entry.options 293 | ) 294 | 295 | await self.flow_handler.hass.config_entries.async_reload( 296 | self.config_entry.entry_id 297 | ) 298 | 299 | return self.flow_handler.async_create_entry(title="", data={}) 300 | -------------------------------------------------------------------------------- /custom_components/lightener/light.py: -------------------------------------------------------------------------------- 1 | """Platform for Lightener lights.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import logging 7 | from types import MappingProxyType 8 | from typing import Any 9 | 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from homeassistant.components.group.light import FORWARDED_ATTRIBUTES, LightGroup 13 | from homeassistant.components.light import ( 14 | ATTR_BRIGHTNESS, 15 | ATTR_TRANSITION, 16 | ColorMode, 17 | ) 18 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 19 | from homeassistant.config_entries import ConfigEntry 20 | from homeassistant.const import ( 21 | ATTR_ENTITY_ID, 22 | CONF_ENTITIES, 23 | CONF_FRIENDLY_NAME, 24 | CONF_LIGHTS, 25 | SERVICE_TURN_OFF, 26 | SERVICE_TURN_ON, 27 | STATE_ON, 28 | ) 29 | from homeassistant.core import HomeAssistant, callback 30 | from homeassistant.exceptions import HomeAssistantError 31 | from homeassistant.helpers.config_validation import PLATFORM_SCHEMA 32 | from homeassistant.helpers.entity import DeviceInfo 33 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 34 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 35 | from homeassistant.util.color import value_to_brightness 36 | 37 | from . import async_migrate_data, async_migrate_entry 38 | from .const import DOMAIN, TYPE_ONOFF 39 | from .util import get_light_type 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | ENTITY_SCHEMA = vol.All( 44 | vol.DefaultTo({1: 1, 100: 100}), 45 | { 46 | vol.All(vol.Coerce(int), vol.Range(min=1, max=100)): vol.All( 47 | vol.Coerce(int), vol.Range(min=0, max=100) 48 | ) 49 | }, 50 | ) 51 | 52 | LIGHT_SCHEMA = vol.Schema( 53 | { 54 | vol.Required(CONF_ENTITIES): {cv.entity_id: ENTITY_SCHEMA}, 55 | vol.Optional(CONF_FRIENDLY_NAME): cv.string, 56 | } 57 | ) 58 | 59 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 60 | {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)} 61 | ) 62 | 63 | 64 | async def async_setup_entry( 65 | hass: HomeAssistant, 66 | config_entry: ConfigEntry, 67 | async_add_entities: AddEntitiesCallback, 68 | ) -> None: 69 | """Set up entities for config entries.""" 70 | unique_id = config_entry.entry_id 71 | 72 | await async_migrate_entry(hass, config_entry) 73 | 74 | # The unique id of the light will simply match the config entry ID. 75 | async_add_entities([LightenerLight(hass, config_entry.data, unique_id)]) 76 | 77 | 78 | async def async_setup_platform( 79 | hass: HomeAssistant, 80 | config: ConfigType, 81 | async_add_entities: AddEntitiesCallback, 82 | discovery_info: DiscoveryInfoType | None = None, # pylint: disable=unused-argument 83 | ) -> None: 84 | """Set up entities for configuration.yaml entries.""" 85 | 86 | lights = [] 87 | 88 | for object_id, entity_config in config[CONF_LIGHTS].items(): 89 | data = await async_migrate_data(entity_config, 1) 90 | data["entity_id"] = object_id 91 | 92 | lights.append(LightenerLight(hass, data)) 93 | 94 | async_add_entities(lights) 95 | 96 | 97 | class LightenerLight(LightGroup): 98 | """Represents a Lightener light.""" 99 | 100 | _is_frozen = False 101 | _prefered_brightness = None 102 | 103 | def __init__( 104 | self, 105 | hass: HomeAssistant, 106 | config_data: MappingProxyType, 107 | unique_id: str | None = None, 108 | ) -> None: 109 | """Initialize the light using the config entry information.""" 110 | 111 | ## Add all entities that are managed by this lightened. 112 | entities: list[LightenerControlledLight] = [] 113 | entity_ids: list[str] = [] 114 | 115 | if config_data.get(CONF_ENTITIES) is not None: 116 | for entity_id, entity_config in config_data[CONF_ENTITIES].items(): 117 | entity_ids.append(entity_id) 118 | entities.append( 119 | LightenerControlledLight(entity_id, entity_config, hass=hass) 120 | ) 121 | 122 | super().__init__( 123 | unique_id=unique_id, 124 | name=config_data[CONF_FRIENDLY_NAME] if unique_id is None else None, 125 | entity_ids=entity_ids, 126 | mode=None, 127 | ) 128 | 129 | self._attr_has_entity_name = unique_id is not None 130 | 131 | if self._attr_has_entity_name: 132 | self._attr_device_info = DeviceInfo( 133 | identifiers={(DOMAIN, self.unique_id)}, 134 | name=config_data[CONF_FRIENDLY_NAME], 135 | ) 136 | 137 | self._entities = entities 138 | 139 | _LOGGER.debug( 140 | "Created lightener `%s`", 141 | config_data[CONF_FRIENDLY_NAME], 142 | ) 143 | 144 | @property 145 | def color_mode(self) -> str: 146 | """Return the color mode of the light.""" 147 | 148 | if not self.is_on: 149 | return None 150 | 151 | # If the controlled lights are on/off only, we force the color mode to BRIGHTNESS 152 | # since Lightner always support it. 153 | if self._attr_color_mode == ColorMode.ONOFF: 154 | return ColorMode.BRIGHTNESS 155 | 156 | # The group may calculate the color mode as UNKNOWN if any of the controlled lights is UNKNOWN. 157 | # We don't want that, so we force it to BRIGHTNESS. 158 | if self._attr_color_mode == ColorMode.UNKNOWN: 159 | return ColorMode.BRIGHTNESS 160 | 161 | return self._attr_color_mode 162 | 163 | @property 164 | def supported_color_modes(self) -> set[str] | None: 165 | """Flag supported color modes.""" 166 | 167 | color_modes = super().supported_color_modes or set() 168 | 169 | # We support BRIGHNESS if the controlled lights are not on/off only. 170 | color_modes.discard(ColorMode.ONOFF) 171 | 172 | if len(color_modes) == 0: 173 | # As a minimum, we support the current color mode, or default to BRIGHTNESS. 174 | if ( 175 | self.color_mode 176 | and self.color_mode != ColorMode.UNKNOWN 177 | and self.color_mode != ColorMode.ONOFF 178 | ): 179 | color_modes.add(self.color_mode) 180 | else: 181 | color_modes.add(ColorMode.BRIGHTNESS) 182 | 183 | return color_modes 184 | 185 | async def async_turn_on(self, **kwargs: Any) -> None: 186 | """Forward the turn_on command to all controlled lights.""" 187 | 188 | # This is basically a copy of LightGroup::async_turn_on but it has been changed 189 | # so we can pass different brightness to each light. 190 | 191 | # List all attributes we want to forward. 192 | data = { 193 | key: value for key, value in kwargs.items() if key in FORWARDED_ATTRIBUTES 194 | } 195 | 196 | # Retrieve the brightness being set to the Lightener 197 | brightness = kwargs.get(ATTR_BRIGHTNESS) 198 | 199 | # If the brightness is not being set, check if it was set in the Lightener. 200 | if brightness is None and self._attr_brightness: 201 | brightness = self._attr_brightness 202 | else: 203 | # Update the Lightener brightness level to the one being set. 204 | self._attr_brightness = brightness 205 | 206 | if brightness is None: 207 | brightness = self._prefered_brightness 208 | else: 209 | self._prefered_brightness = brightness 210 | 211 | _LOGGER.debug( 212 | "[Turn On] Attempting to set brightness of `%s` to `%s`", 213 | self.entity_id, 214 | brightness, 215 | ) 216 | 217 | self._is_frozen = True 218 | 219 | async def _safe_service_call( 220 | entity: LightenerControlledLight, service: str, entity_data: dict 221 | ) -> None: 222 | """Call a service for an entity, logging success and guarding failures.""" 223 | try: 224 | await self.hass.services.async_call( 225 | LIGHT_DOMAIN, 226 | service, 227 | entity_data, 228 | blocking=True, 229 | context=self._context, 230 | ) 231 | _LOGGER.debug( 232 | "Service `%s` called for `%s` (%s) with `%s`", 233 | service, 234 | entity.entity_id, 235 | entity.type, 236 | entity_data, 237 | ) 238 | except Exception as exc: # noqa: BLE001 239 | _LOGGER.exception( 240 | "Service `%s` for `%s` (%s) failed: %s; payload=%s", 241 | service, 242 | entity.entity_id, 243 | entity.type, 244 | exc, 245 | entity_data, 246 | ) 247 | 248 | async with asyncio.TaskGroup() as group: 249 | for entity in self._entities: 250 | service = SERVICE_TURN_ON 251 | entity_brightness = None 252 | 253 | # If the brightness is being set in the lightener, translate it to the entity level. 254 | if brightness is not None: 255 | entity_brightness = entity.translate_brightness(brightness) 256 | 257 | # If the light brightness level is zero, we turn it off instead. 258 | if entity_brightness == 0: 259 | service = SERVICE_TURN_OFF 260 | entity_data = {} 261 | 262 | # "Transition" is the only additional data allowed with the turn_off service. 263 | if ATTR_TRANSITION in data: 264 | entity_data[ATTR_TRANSITION] = data[ATTR_TRANSITION] 265 | else: 266 | # Make a copy of the data being sent to the lightener call so we can modify it. 267 | entity_data = data.copy() 268 | 269 | # Set the translated brightness level. 270 | if brightness is not None: 271 | entity_data[ATTR_BRIGHTNESS] = entity_brightness 272 | 273 | # Set the proper entity ID. 274 | entity_data[ATTR_ENTITY_ID] = entity.entity_id 275 | 276 | # Submit the service call concurrently, guarded to avoid cancelling siblings on failure. 277 | group.create_task(_safe_service_call(entity, service, entity_data)) 278 | 279 | self._is_frozen = False 280 | 281 | # Define a coroutine as a ha task. 282 | async def _async_refresh() -> None: 283 | """Turn on all lights controlled by this Lightener.""" 284 | self.async_update_group_state() 285 | self.async_write_ha_state() 286 | 287 | # Schedule the task to run. 288 | self.hass.async_create_task( 289 | _async_refresh(), name="Lightener [turn_on refresh]" 290 | ) 291 | 292 | async def async_turn_off(self, **kwargs: Any) -> None: 293 | """Turn off all lights controlled by this Lightener.""" 294 | self._is_frozen = True 295 | 296 | self._prefered_brightness = self._attr_brightness 297 | 298 | await super().async_turn_off(**kwargs) 299 | 300 | _LOGGER.debug("[Turn Off] Turned off `%s`", self.entity_id) 301 | 302 | self._is_frozen = False 303 | self.async_update_group_state() 304 | self.async_write_ha_state() 305 | 306 | def turn_on(self, **kwargs: Any) -> None: 307 | """Turn the lights controlled by this Lightener on. There is no guarantee that this method is synchronous.""" 308 | self.async_turn_on(**kwargs) 309 | 310 | def turn_off(self, **kwargs: Any) -> None: 311 | """Turn the lights controlled by this Lightener off. There is no guarantee that this method is synchronous.""" 312 | self.async_turn_off(**kwargs) 313 | 314 | @callback 315 | def async_update_group_state(self) -> None: 316 | """Update the Lightener state based on the controlled entities.""" 317 | 318 | if self._is_frozen: 319 | return 320 | 321 | was_off = not self.is_on 322 | current_brightness = self._attr_brightness 323 | 324 | # Flag is this update is caused by this Lightener when calling turn_on. 325 | is_lightener_change = False 326 | 327 | # Let the Group integration make its magic, which includes recalculating the brightness. 328 | super().async_update_group_state() 329 | 330 | common_level: set = None 331 | 332 | if self.is_on: 333 | # Calculates the brighteness by checking if the current levels in al controlled lights 334 | # preciselly match one of the possible values for this lightener. 335 | levels = [] 336 | for entity_id in self._entity_ids: 337 | state = self.hass.states.get(entity_id) 338 | 339 | # State may return None if the entity is not available, so we ignore it. 340 | if state is not None: 341 | for entity in self._entities: 342 | if entity.entity_id == state.entity_id: 343 | # Check if the entity state change is caused by this Lightener. 344 | is_lightener_change = ( 345 | True 346 | if is_lightener_change 347 | else ( 348 | state.context 349 | and self._context 350 | and state.context.id == self._context.id 351 | ) 352 | ) 353 | 354 | if state.state == STATE_ON: 355 | entity_brightness = state.attributes.get( 356 | ATTR_BRIGHTNESS, 255 357 | ) 358 | else: 359 | entity_brightness = 0 360 | 361 | _LOGGER.debug( 362 | "Current brightness of `%s` is `%s`", 363 | entity.entity_id, 364 | entity_brightness, 365 | ) 366 | 367 | if entity_brightness is not None: 368 | levels.append( 369 | entity.translate_brightness_back(entity_brightness) 370 | ) 371 | else: 372 | levels.append([]) 373 | 374 | if levels: 375 | # If the current lightener level is not present in the possible levels of the controlled lights. 376 | if len({self._prefered_brightness}.intersection(*map(set, levels))) > 0: 377 | common_level = {self._prefered_brightness} 378 | else: 379 | # Build a list of levels which are common for all lights. 380 | common_level = set.intersection(*map(set, levels)) 381 | 382 | if common_level: 383 | # Use the common level if any was found. 384 | self._attr_brightness = common_level.pop() 385 | else: 386 | self._attr_brightness = ( 387 | self._prefered_brightness 388 | if is_lightener_change 389 | else current_brightness 390 | if self.is_on or was_off 391 | else None 392 | ) 393 | 394 | _LOGGER.debug( 395 | "Setting the brightness of `%s` to `%s`", 396 | self.entity_id, 397 | self._attr_brightness, 398 | ) 399 | 400 | @callback 401 | def async_write_ha_state(self) -> None: 402 | """Write the state to the state machine.""" 403 | 404 | if self._is_frozen: 405 | return 406 | 407 | _LOGGER.debug( 408 | "Writing state of `%s` with brightness `%s`", 409 | self.entity_id, 410 | self._attr_brightness, 411 | ) 412 | 413 | super().async_write_ha_state() 414 | 415 | 416 | class LightenerControlledLight: 417 | """Represents a light entity managed by a LightnerLight.""" 418 | 419 | def __init__( 420 | self: LightenerControlledLight, 421 | entity_id: str, 422 | config: dict, 423 | hass: HomeAssistant, 424 | ) -> None: 425 | """Create and instance of this class.""" 426 | 427 | self.entity_id = entity_id 428 | self.hass = hass 429 | 430 | # Get the brightness configuration and prepare it for processing, 431 | brightness_config = prepare_brightness_config(config.get("brightness", {})) 432 | 433 | # Create the brightness conversion maps (from lightener to entity and from entity to lightener). 434 | self.levels = create_brightness_map(brightness_config) 435 | self.to_lightener_levels = create_reverse_brightness_map( 436 | brightness_config, self.levels 437 | ) 438 | self.to_lightener_levels_on_off = create_reverse_brightness_map_on_off( 439 | self.to_lightener_levels 440 | ) 441 | 442 | @property 443 | def type(self) -> str | None: 444 | """The entity type.""" 445 | 446 | try: 447 | return get_light_type(self.hass, self.entity_id) 448 | except HomeAssistantError: 449 | return None 450 | 451 | def translate_brightness(self, brightness: int) -> int: 452 | """Calculate the entitiy brightness for the give Lightener brightness level.""" 453 | 454 | level = self.levels.get(int(brightness)) 455 | 456 | if self.type == TYPE_ONOFF: 457 | return 0 if level == 0 else 255 458 | 459 | return level 460 | 461 | def translate_brightness_back(self, brightness: int) -> list[int]: 462 | """Calculate all possible Lightener brightness levels for a give entity brightness.""" 463 | 464 | if brightness is None: 465 | return [] 466 | 467 | levels = self.to_lightener_levels.get(int(brightness)) 468 | 469 | if self.type == TYPE_ONOFF: 470 | return self.to_lightener_levels_on_off[int(brightness)] 471 | 472 | return levels 473 | 474 | 475 | def translate_config_to_brightness(config: dict) -> dict: 476 | """Create a copy of config converting the 0-100 range to 1-255. 477 | 478 | Convert the values to integers since the original values are strings. 479 | """ 480 | 481 | return { 482 | value_to_brightness((1, 100), int(k)): 0 483 | if int(v) == 0 484 | else value_to_brightness((1, 100), int(v)) 485 | for k, v in config.items() 486 | } 487 | 488 | 489 | def prepare_brightness_config(config: dict) -> dict: 490 | """Convert the brightness configuration to a list of tuples and sorts it by the lightener level. 491 | 492 | Also add the default 0 and 255 levels if they are not present. 493 | """ 494 | 495 | config = translate_config_to_brightness(config) 496 | 497 | # Zero must always be zero. 498 | config[0] = 0 499 | 500 | # If the maximum level is not present, add it. 501 | config.setdefault(255, 255) 502 | 503 | # Transform the dictionary into a list of tuples and sort it by the lightener level. 504 | config = sorted(config.items()) 505 | 506 | return config 507 | 508 | 509 | def create_brightness_map(config: list) -> dict: 510 | """Create a mapping of lightener levels to entity levels.""" 511 | 512 | brightness_map = {0: 0} 513 | 514 | for i in range(1, len(config)): 515 | start, end = config[i - 1][0], config[i][0] 516 | start_value, end_value = config[i - 1][1], config[i][1] 517 | for j in range(start + 1, end + 1): 518 | brightness_map[j] = scale_ranged_value_to_int_range( 519 | (start, end), (start_value, end_value), j 520 | ) 521 | 522 | return brightness_map 523 | 524 | 525 | def create_reverse_brightness_map(config: list, lightener_levels: dict) -> dict: 526 | """Create a map with all entity level (from 0 to 255) to all possible lightener levels at each entity level. 527 | 528 | There can be multiple lightener levels for a single entity level. 529 | """ 530 | 531 | # Initialize with all levels from 0 to 255. 532 | reverse_brightness_map = {i: [] for i in range(256)} 533 | 534 | # Initialize entries with all lightener levels (it goes from 0 to 255) 535 | for k, v in lightener_levels.items(): 536 | reverse_brightness_map[v].append(k) 537 | 538 | # Now fill the gaps in the map by looping though the configured entity ranges 539 | for i in range(1, len(config)): 540 | start, end = config[i - 1][0], config[i][0] 541 | start_value, end_value = config[i - 1][1], config[i][1] 542 | 543 | # If there is an entity range to be covered 544 | if start_value != end_value: 545 | order = 1 if start_value < end_value else -1 546 | 547 | # Loop through the entity range 548 | for j in range(start_value, end_value + order, order): 549 | entity_level = scale_ranged_value_to_int_range( 550 | (start_value, end_value), (start, end), j 551 | ) 552 | # If the entry is not yet present for into that level, add it. 553 | if entity_level not in reverse_brightness_map[j]: 554 | reverse_brightness_map[j].append(entity_level) 555 | 556 | return reverse_brightness_map 557 | 558 | 559 | def create_reverse_brightness_map_on_off(reverse_map: dict) -> dict: 560 | """Create a reversed map dedicated to on/off lights.""" 561 | 562 | # Build the "on" state out of all levels which are not in the "off" state. 563 | on_levels = [i for i in range(1, 256) if i not in reverse_map[0]] 564 | 565 | # The "on" levels are possible for all non-zero levels. 566 | reverse_map_on_off = dict.fromkeys(range(1, 256), on_levels) 567 | 568 | # The "off" matches the normal reverse map. 569 | reverse_map_on_off[0] = reverse_map[0] 570 | 571 | return reverse_map_on_off 572 | 573 | 574 | def scale_ranged_value_to_int_range( 575 | source_range: tuple[float, float], 576 | target_range: tuple[float, float], 577 | value: float, 578 | ) -> int: 579 | """Scale a value from one range to another and return an integer.""" 580 | 581 | # Unpack the original and target ranges 582 | (a, b) = source_range 583 | (c, d) = target_range 584 | 585 | # Calculate the conversion 586 | y = c + ((value - a) * (d - c)) / (b - a) 587 | return round(y) 588 | -------------------------------------------------------------------------------- /tests/components/lightener/test_light.py: -------------------------------------------------------------------------------- 1 | """Tests for the light platform.""" 2 | 3 | from unittest.mock import ANY, Mock, patch 4 | from uuid import uuid4 5 | 6 | import pytest 7 | from homeassistant.components.light import ATTR_TRANSITION, ColorMode 8 | from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 9 | from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON 10 | from homeassistant.core import HomeAssistant, ServiceRegistry 11 | 12 | from custom_components.lightener.const import TYPE_DIMMABLE, TYPE_ONOFF 13 | from custom_components.lightener.light import ( 14 | LightenerControlledLight, 15 | LightenerLight, 16 | async_setup_platform, 17 | create_brightness_map, 18 | create_reverse_brightness_map, 19 | create_reverse_brightness_map_on_off, 20 | prepare_brightness_config, 21 | scale_ranged_value_to_int_range, 22 | translate_config_to_brightness, 23 | ) 24 | 25 | 26 | async def test_turn_on_resilient_to_single_failure( 27 | hass: HomeAssistant, create_lightener 28 | ): 29 | """Ensure a failure in one entity service call does not cancel other calls.""" 30 | 31 | # Create a lightener with two lights 32 | lightener: LightenerLight = await create_lightener( 33 | config={ 34 | "friendly_name": "Test", 35 | "entities": { 36 | "light.test1": {}, 37 | "light.test2": {}, 38 | }, 39 | } 40 | ) 41 | 42 | calls: list[tuple] = [] 43 | 44 | # Capture original class method so successful calls can delegate 45 | orig_async_call = ServiceRegistry.async_call 46 | 47 | async def fake_async_call(self, domain, service, data, blocking=True, context=None): 48 | calls.append((domain, service, data.get(ATTR_ENTITY_ID))) 49 | if data.get(ATTR_ENTITY_ID) == "light.test1": 50 | raise RuntimeError("boom") 51 | return await orig_async_call( 52 | self, domain, service, data, blocking=blocking, context=context 53 | ) 54 | 55 | # Patch the class method with autospec so `self` is passed 56 | with patch.object( 57 | ServiceRegistry, "async_call", side_effect=fake_async_call, autospec=True 58 | ): 59 | await lightener.async_turn_on(brightness=128) 60 | await hass.async_block_till_done() 61 | 62 | # Both calls were attempted (order not guaranteed due to concurrency) 63 | attempted = sorted([c[2] for c in calls]) 64 | assert attempted == ["light.test1", "light.test2"] 65 | 66 | # light.test1 failed 67 | assert hass.states.get("light.test1").state == "off" 68 | 69 | # light.test2 should have ended up on despite light.test1 failing 70 | assert hass.states.get("light.test2").state == "on" 71 | 72 | 73 | ########################################################### 74 | ### LightenerLight class only tests 75 | 76 | 77 | async def test_lightener_light_properties(hass): 78 | """Test all the basic properties of the LightenerLight class.""" 79 | 80 | config = {"friendly_name": "Living Room"} 81 | unique_id = str(uuid4()) 82 | 83 | lightener = LightenerLight(hass, config, unique_id) 84 | 85 | assert lightener.unique_id == unique_id 86 | 87 | # Name must be empty so it'll be taken from the device 88 | assert lightener.name is None 89 | assert lightener.device_info["name"] == "Living Room" 90 | 91 | assert lightener.should_poll is False 92 | assert lightener.has_entity_name is True 93 | 94 | assert lightener.icon == "mdi:lightbulb-group" 95 | 96 | 97 | async def test_lightener_light_properties_no_unique_id(hass): 98 | """Test all the basic properties of the LightenerLight class when no unique id is provided.""" 99 | 100 | config = {"friendly_name": "Living Room"} 101 | 102 | lightener = LightenerLight(hass, config) 103 | 104 | assert lightener.unique_id is None 105 | assert lightener.device_info is None 106 | assert lightener.name == "Living Room" 107 | 108 | 109 | async def test_lightener_light_turn_on(hass: HomeAssistant, create_lightener): 110 | """Test the state changes of the LightenerLight class when turned on.""" 111 | 112 | lightener: LightenerLight = await create_lightener( 113 | config={ 114 | "friendly_name": "Test", 115 | "entities": { 116 | "light.test1": {}, 117 | "light.test2": {}, 118 | }, 119 | } 120 | ) 121 | 122 | await lightener.async_turn_on() 123 | await hass.async_block_till_done() 124 | 125 | assert hass.states.get("light.test1").state == "on" 126 | assert hass.states.get("light.test2").state == "on" 127 | 128 | 129 | async def test_lightener_light_turn_on_forward(hass: HomeAssistant, create_lightener): # pylint: disable=unused-argument 130 | """Test if passed arguments are forwared when turned on.""" 131 | 132 | lightener: LightenerLight = await create_lightener() 133 | 134 | with patch.object(ServiceRegistry, "async_call") as async_call_mock: 135 | await lightener.async_turn_on( 136 | brightness=50, effect="blink", color_temp_kelvin=3000 137 | ) 138 | 139 | async_call_mock.assert_called_once_with( 140 | LIGHT_DOMAIN, 141 | SERVICE_TURN_ON, 142 | { 143 | ATTR_ENTITY_ID: "light.test1", 144 | "brightness": 50, 145 | "effect": "blink", 146 | "color_temp_kelvin": 3000, 147 | }, 148 | blocking=True, 149 | context=ANY, 150 | ) 151 | 152 | 153 | async def test_lightener_light_turn_on_go_off_if_brightness_0( 154 | hass: HomeAssistant, create_lightener 155 | ): 156 | """Test that turned on sends brightness 0 if the controlled light is on.""" 157 | 158 | lightener: LightenerLight = await create_lightener( 159 | config={ 160 | "friendly_name": "Test", 161 | "entities": {"light.test1": {"50": "0"}}, 162 | } 163 | ) 164 | 165 | hass.states.async_set(entity_id="light.test1", new_state="on") 166 | 167 | await lightener.async_turn_on(brightness=1) 168 | await hass.async_block_till_done() 169 | 170 | assert hass.states.get("light.test1").state == "off" 171 | 172 | 173 | async def test_lightener_light_turn_on_translate_brightness( 174 | hass: HomeAssistant, create_lightener 175 | ): 176 | """Test that turned on sends brightness 0 if the controlled light is on.""" 177 | 178 | lightener: LightenerLight = await create_lightener( 179 | config={ 180 | "friendly_name": "Test", 181 | "entities": {"light.test1": {"50": "0"}}, 182 | } 183 | ) 184 | hass.states.async_set(entity_id="light.test1", new_state="on") 185 | 186 | await lightener.async_turn_on(brightness=192) 187 | await hass.async_block_till_done() 188 | 189 | assert hass.states.get("light.test1").state == "on" 190 | assert hass.states.get("light.test1").attributes["brightness"] == 129 191 | 192 | 193 | async def test_lightener_light_turn_on_go_off_if_brightness_0_transition( 194 | hass: HomeAssistant, create_lightener 195 | ): 196 | """Test that turned on sends brightness 0 if the controlled light is on.""" 197 | 198 | lightener: LightenerLight = await create_lightener( 199 | config={ 200 | "friendly_name": "Test", 201 | "entities": {"light.test1": {"50": "0"}}, 202 | } 203 | ) 204 | 205 | hass.states.async_set(entity_id="light.test1", new_state="on") 206 | 207 | with patch.object(ServiceRegistry, "async_call") as async_call_mock: 208 | await lightener.async_turn_on(brightness=1, transition=10) 209 | 210 | async_call_mock.assert_called_once_with( 211 | LIGHT_DOMAIN, 212 | SERVICE_TURN_OFF, 213 | {ATTR_ENTITY_ID: "light.test1", ATTR_TRANSITION: 10}, 214 | blocking=True, 215 | context=ANY, 216 | ) 217 | 218 | 219 | async def test_lightener_light_color_mode_xy(hass: HomeAssistant, create_lightener): 220 | """Test that Lightener inherits the color mode of the controlled lights.""" 221 | 222 | lightener: LightenerLight = await create_lightener( 223 | config={ 224 | "friendly_name": "Test", 225 | "entities": {"light.test1": {}}, 226 | } 227 | ) 228 | 229 | hass.states.async_set( 230 | entity_id="light.test1", 231 | new_state="on", 232 | attributes={"color_mode": ColorMode.XY}, 233 | ) 234 | 235 | await lightener.async_update_ha_state() 236 | await hass.async_block_till_done() 237 | 238 | assert hass.states.get("light.test1").attributes["color_mode"] == ColorMode.XY 239 | 240 | assert lightener.color_mode == ColorMode.XY 241 | assert lightener.supported_color_modes == {ColorMode.XY} 242 | 243 | 244 | async def test_lightener_light_color_mode_onoff(hass: HomeAssistant, create_lightener): 245 | """Test that Lightener keeps its color mode to BRIGHTNESS with an ONOFF controlled light.""" 246 | 247 | lightener: LightenerLight = await create_lightener( 248 | config={ 249 | "friendly_name": "Test", 250 | "entities": {"light.test_onoff": {}}, 251 | } 252 | ) 253 | 254 | hass.states.async_set( 255 | entity_id="light.test_onoff", 256 | new_state="on", 257 | attributes={"color_mode": ColorMode.ONOFF}, 258 | ) 259 | 260 | await lightener.async_turn_on(brightness=1) 261 | await hass.async_block_till_done() 262 | 263 | assert lightener.color_mode == ColorMode.BRIGHTNESS 264 | assert lightener.supported_color_modes == {ColorMode.BRIGHTNESS} 265 | 266 | assert ( 267 | hass.states.get("light.test_onoff").attributes["color_mode"] == ColorMode.ONOFF 268 | ) 269 | 270 | # Assert that the color_mode goes to null when the light is turned off 271 | await lightener.async_turn_off() 272 | await hass.async_block_till_done() 273 | 274 | assert lightener.color_mode is None 275 | assert lightener.supported_color_modes == {ColorMode.BRIGHTNESS} 276 | 277 | 278 | async def test_lightener_light_color_mode_unknown( 279 | hass: HomeAssistant, create_lightener 280 | ): 281 | """Test that Lightener keeps its color mode to BRIGHTNESS with a controlled light that has color_mode UNKNOWN.""" 282 | 283 | lightener: LightenerLight = await create_lightener( 284 | config={ 285 | "friendly_name": "Test", 286 | "entities": {"light.test_temp": {}}, 287 | } 288 | ) 289 | 290 | hass.states.async_set( 291 | entity_id="light.test_temp", 292 | new_state="on", 293 | attributes={"color_mode": ColorMode.UNKNOWN}, 294 | ) 295 | 296 | await lightener.async_turn_on(brightness=1) 297 | await hass.async_block_till_done() 298 | 299 | assert lightener.color_mode == ColorMode.BRIGHTNESS 300 | 301 | assert ( 302 | hass.states.get("light.test_temp").attributes["color_mode"] == ColorMode.UNKNOWN 303 | ) 304 | 305 | # Assert that the color_mode goes to null when the light is turned off 306 | await lightener.async_turn_off() 307 | await hass.async_block_till_done() 308 | 309 | assert lightener.color_mode is None 310 | 311 | 312 | async def test_lightener_light_async_update_group_state( 313 | hass: HomeAssistant, create_lightener 314 | ): 315 | """Test that turned on does nothing if the controlled light is already off.""" 316 | 317 | lightener: LightenerLight = await create_lightener( 318 | config={ 319 | "friendly_name": "Test", 320 | "entities": {"light.test1": {"50": "0"}}, 321 | } 322 | ) 323 | 324 | lightener._attr_brightness = 150 # pylint: disable=protected-access 325 | 326 | hass.states.async_set( 327 | entity_id="light.test1", new_state="on", attributes={"color_temp_kelvin": 3000} 328 | ) 329 | 330 | lightener.async_update_group_state() 331 | 332 | assert lightener.is_on is True 333 | assert lightener.color_temp_kelvin == 3000 334 | 335 | assert lightener.brightness == 255 336 | 337 | hass.states.async_set( 338 | entity_id="light.test1", new_state="on", attributes={"brightness": 255} 339 | ) 340 | 341 | lightener.async_update_group_state() 342 | 343 | assert lightener.brightness == 255 344 | 345 | hass.states.async_set( 346 | entity_id="light.test1", new_state="on", attributes={"brightness": 1} 347 | ) 348 | 349 | lightener.async_update_group_state() 350 | 351 | assert lightener.brightness == 128 352 | 353 | hass.states.async_set( 354 | entity_id="light.test1", new_state="on", attributes={"brightness": 0} 355 | ) 356 | 357 | lightener.async_update_group_state() 358 | 359 | assert lightener.is_on is True 360 | assert lightener.brightness == 0 361 | 362 | 363 | async def test_lightener_light_async_update_group_state_zero( 364 | hass: HomeAssistant, create_lightener 365 | ): 366 | """Test that turned on does nothing if the controlled light is already off.""" 367 | 368 | lightener: LightenerLight = await create_lightener( 369 | config={ 370 | "friendly_name": "Test", 371 | "entities": {"light.test1": {}}, 372 | } 373 | ) 374 | 375 | lightener._attr_brightness = 150 # pylint: disable=protected-access 376 | 377 | hass.states.async_set( 378 | entity_id="light.test1", new_state="on", attributes={"brightness": 0} 379 | ) 380 | 381 | lightener.async_update_group_state() 382 | 383 | assert lightener.brightness == 0 384 | 385 | 386 | async def test_lightener_light_async_update_group_state_unavailable( 387 | hass: HomeAssistant, create_lightener 388 | ): 389 | """Test that turned on does nothing if the controlled light is already off.""" 390 | 391 | lightener: LightenerLight = await create_lightener( 392 | config={ 393 | "friendly_name": "Test", 394 | "entities": {"light.test1": {"50": "0"}, "light.I_DONT_EXIST": {}}, 395 | } 396 | ) 397 | 398 | lightener._attr_brightness = 150 # pylint: disable=protected-access 399 | 400 | hass.states.async_set( 401 | entity_id="light.test1", new_state="on", attributes={"brightness": 1} 402 | ) 403 | 404 | lightener.async_update_group_state() 405 | 406 | assert lightener.brightness == 128 407 | 408 | 409 | async def test_lightener_light_async_update_group_state_no_match_no_change( 410 | hass: HomeAssistant, create_lightener 411 | ): 412 | """Test that turned on does nothing if the controlled light is already off.""" 413 | 414 | lightener: LightenerLight = await create_lightener( 415 | config={ 416 | "friendly_name": "Test", 417 | "entities": {"light.test1": {"50": "0"}, "light.test2": {"10": "100"}}, 418 | } 419 | ) 420 | 421 | def test(test1: int, test2: int, result: int): 422 | lightener._attr_brightness = 150 # pylint: disable=protected-access 423 | 424 | hass.states.async_set( 425 | entity_id="light.test1", new_state="on", attributes={"brightness": test1} 426 | ) 427 | 428 | hass.states.async_set( 429 | entity_id="light.test2", new_state="on", attributes={"brightness": test2} 430 | ) 431 | 432 | lightener.async_update_group_state() 433 | 434 | assert lightener.brightness == result 435 | 436 | # Matches 437 | test(0, 29, 3) 438 | test(1, 255, 128) 439 | 440 | # No matches 441 | test(129, 1, 150) 442 | test(1, 254, 150) 443 | test(1, 1, 150) 444 | test(1, None, 150) 445 | 446 | 447 | @pytest.mark.parametrize( 448 | "test1, current, result", 449 | [ 450 | (0, 10, 10), 451 | (0, 20, 20), 452 | # We're in the range, so the change must happen here. 453 | (128, 20, 141), 454 | (255, 200, 200), 455 | (255, 255, 255), 456 | ], 457 | ) 458 | async def test_lightener_light_async_update_group_state_current_good_no_change( 459 | test1, current, result, hass: HomeAssistant, create_lightener 460 | ): 461 | """Test that turned on does nothing if the controlled light is already off.""" 462 | 463 | lightener: LightenerLight = await create_lightener( 464 | config={ 465 | "friendly_name": "Test", 466 | "entities": {"light.test1": {"50": "0", "60": "100"}}, 467 | } 468 | ) 469 | 470 | lightener._prefered_brightness = current # pylint: disable=protected-access 471 | 472 | hass.states.async_set( 473 | entity_id="light.test1", new_state="on", attributes={"brightness": test1} 474 | ) 475 | 476 | lightener.async_update_group_state() 477 | 478 | assert lightener.brightness == result 479 | 480 | 481 | async def test_lightener_light_async_update_group_state_onoff( 482 | hass: HomeAssistant, create_lightener 483 | ): 484 | """Test that turned on does nothing if the controlled light is already off.""" 485 | 486 | lightener: LightenerLight = await create_lightener( 487 | config={ 488 | "friendly_name": "Test", 489 | "entities": {"light.test_onoff": {}}, 490 | } 491 | ) 492 | 493 | # lightener._attr_brightness = 150 # pylint: disable=protected-access 494 | 495 | hass.states.async_set( 496 | entity_id="light.test_onoff", 497 | new_state="on", 498 | attributes={"color_mode": ColorMode.ONOFF}, 499 | ) 500 | 501 | lightener.async_update_group_state() 502 | 503 | assert lightener.color_mode == ColorMode.BRIGHTNESS 504 | assert lightener.supported_color_modes == {ColorMode.BRIGHTNESS} 505 | 506 | 507 | ########################################################### 508 | ### LightenerControlledLight class only tests 509 | 510 | 511 | async def test_lightener_light_entity_properties(hass): 512 | """Test all the basic properties of the LightenerLight class.""" 513 | 514 | light = LightenerControlledLight("light.test1", {"brightness": {"10": "20"}}, hass) 515 | 516 | assert light.entity_id == "light.test1" 517 | 518 | 519 | async def test_lightener_light_entity_calculated_levels(hass): 520 | """Test the calculation of brigthness levels.""" 521 | 522 | light = LightenerControlledLight( 523 | "light.test1", 524 | { 525 | "brightness": { 526 | "10": "100", 527 | } 528 | }, 529 | hass, 530 | ) 531 | 532 | assert light.levels[0] == 0 533 | assert light.levels[13] == 128 534 | assert light.levels[25] == 245 535 | assert light.levels[26] == 255 536 | assert light.levels[27] == 255 537 | assert light.levels[100] == 255 538 | assert light.levels[255] == 255 539 | 540 | light = LightenerControlledLight( 541 | "light.test1", 542 | { 543 | "brightness": { 544 | "100": "0", # Test the ordering 545 | "10": "10", 546 | "50": "100", 547 | } 548 | }, 549 | hass, 550 | ) 551 | 552 | assert light.levels[0] == 0 553 | assert light.levels[15] == 15 554 | assert light.levels[26] == 26 555 | assert light.levels[27] == 28 556 | assert light.levels[128] == 255 557 | assert light.levels[129] == 253 558 | assert light.levels[255] == 0 559 | 560 | 561 | async def test_lightener_light_entity_calculated_to_lightner_levels(hass): 562 | """Test the calculation of brigthness levels.""" 563 | 564 | light = LightenerControlledLight( 565 | "light.test1", 566 | { 567 | "brightness": { 568 | "10": "100" # 26: 255 569 | } 570 | }, 571 | hass, 572 | ) 573 | 574 | assert light.to_lightener_levels[0] == [0] 575 | assert light.to_lightener_levels[26] == [3] 576 | assert light.to_lightener_levels[253] == [26] 577 | assert light.to_lightener_levels[254] == [26] 578 | assert light.to_lightener_levels[255] == list(range(26, 256)) 579 | 580 | light = LightenerControlledLight( 581 | "light.test1", 582 | { 583 | "brightness": { 584 | "100": "0", # Test the ordering 585 | "10": "10", 586 | "50": "100", 587 | } 588 | }, 589 | hass, 590 | ) 591 | 592 | assert light.to_lightener_levels[0] == [0, 255] 593 | assert light.to_lightener_levels[26] == [26, 242] 594 | assert light.to_lightener_levels[255] == [128] 595 | 596 | assert light.to_lightener_levels[3] == [3, 254] 597 | assert light.to_lightener_levels[10] == [10, 250] 598 | 599 | 600 | @pytest.mark.parametrize( 601 | "entity_id, expected_type", 602 | [ 603 | ("light.test1", TYPE_DIMMABLE), 604 | ("light.test_onoff", TYPE_ONOFF), 605 | ], 606 | ) 607 | async def test_lightener_light_entity_type(entity_id, expected_type, hass): 608 | """Test translate_brightness_back with float values.""" 609 | 610 | light = LightenerControlledLight( 611 | entity_id, 612 | {}, 613 | hass, 614 | ) 615 | 616 | assert light.type is expected_type 617 | 618 | 619 | @pytest.mark.parametrize( 620 | "lightener_level, light_level", 621 | [ 622 | (0, 0), 623 | (1, 10), 624 | (26, 255), 625 | (39, 122), 626 | (255, 0), 627 | ], 628 | ) 629 | async def test_lightener_light_entity_translate_brightness_dimmable( 630 | lightener_level, light_level, hass 631 | ): 632 | """Test translate_brightness_back with float values.""" 633 | 634 | light = LightenerControlledLight( 635 | "light.test1", 636 | { 637 | "brightness": { 638 | "10": "100", 639 | "20": "0", 640 | "100": "0", 641 | } 642 | }, 643 | hass, 644 | ) 645 | 646 | assert light.translate_brightness(lightener_level) == light_level 647 | 648 | 649 | @pytest.mark.parametrize( 650 | "lightener_level, light_level", 651 | [ 652 | (0, 0), 653 | (1, 255), 654 | (26, 255), 655 | (39, 255), 656 | (255, 0), 657 | ], 658 | ) 659 | async def test_lightener_light_entity_translate_brightness_dimmable_onoff( 660 | lightener_level, light_level, hass 661 | ): 662 | """Test translate_brightness_back with float values.""" 663 | 664 | light = LightenerControlledLight( 665 | "light.test_onoff", 666 | {"brightness": {"10": "100", "20": "0", "100": "0"}}, 667 | hass, 668 | ) 669 | 670 | assert light.translate_brightness(lightener_level) == light_level 671 | 672 | 673 | async def test_lightener_light_entity_translate_brightness_float(hass): 674 | """Test translate_brightness_back with float values.""" 675 | 676 | light = LightenerControlledLight( 677 | "light.test1", 678 | { 679 | "brightness": { 680 | "10": "100" # 26: 255 681 | } 682 | }, 683 | hass, 684 | ) 685 | 686 | assert light.translate_brightness(2.9) == 20 687 | 688 | 689 | async def test_lightener_light_entity_translate_brightness_back_float(hass): 690 | """Test translate_brightness_back with float values.""" 691 | 692 | light = LightenerControlledLight( 693 | "light.test1", 694 | { 695 | "brightness": { 696 | "10": "100" # 26: 255 697 | } 698 | }, 699 | hass, 700 | ) 701 | 702 | assert light.translate_brightness_back(25.9) == [3] 703 | 704 | 705 | ########################################################### 706 | ### Other 707 | 708 | 709 | async def test_async_setup_platform(hass): 710 | """Test for platform setup.""" 711 | 712 | # pylint: disable=W0212 713 | 714 | async_add_entities_mock = Mock() 715 | 716 | config = { 717 | "platform": "lightener", 718 | "lights": { 719 | "lightener_1": { 720 | "friendly_name": "Lightener 1", 721 | "entities": {"light.test1": {10: 100}}, 722 | }, 723 | "lightener_2": { 724 | "friendly_name": "Lightener 2", 725 | "entities": {"light.test2": {100: 10}}, 726 | }, 727 | }, 728 | } 729 | 730 | await async_setup_platform(hass, config, async_add_entities_mock) 731 | 732 | assert async_add_entities_mock.call_count == 1 733 | 734 | created_lights: list = async_add_entities_mock.call_args.args[0] 735 | 736 | assert len(created_lights) == 2 737 | 738 | light: LightenerLight = created_lights[0] 739 | 740 | assert isinstance(light, LightenerLight) 741 | assert light.name == "Lightener 1" 742 | assert len(light._entities) == 1 743 | 744 | controlled_light: LightenerControlledLight = light._entities[0] 745 | 746 | assert isinstance(controlled_light, LightenerControlledLight) 747 | assert controlled_light.entity_id == "light.test1" 748 | assert controlled_light.levels[26] == 255 749 | 750 | light: LightenerLight = created_lights[1] 751 | 752 | assert isinstance(light, LightenerLight) 753 | assert light.name == "Lightener 2" 754 | assert len(light._entities) == 1 755 | 756 | controlled_light: LightenerControlledLight = light._entities[0] 757 | 758 | assert isinstance(controlled_light, LightenerControlledLight) 759 | assert light.extra_state_attributes["entity_id"][0] == "light.test2" 760 | assert controlled_light.entity_id == "light.test2" 761 | assert controlled_light.levels[255] == 26 762 | 763 | 764 | @pytest.mark.parametrize( 765 | "config, expected_result", 766 | [ 767 | # Normal configuration 768 | ( 769 | { 770 | "10": "50", 771 | "20": "0", 772 | "30": "100", 773 | }, 774 | { 775 | 26: 128, 776 | 51: 0, 777 | 76: 255, 778 | }, 779 | ), 780 | # Empty configuration 781 | ({}, {}), 782 | # Zero values 783 | ({"10": "0"}, {26: 0}), 784 | # 100% values 785 | ({"100": "100"}, {255: 255}), 786 | ], 787 | ) 788 | def test_translate_config_to_brightness(config, expected_result): 789 | """Test the translate_config_to_brightness function.""" 790 | 791 | assert translate_config_to_brightness(config) == expected_result 792 | 793 | 794 | @pytest.mark.parametrize( 795 | "config, expected_result", 796 | [ 797 | # Normal configuration 798 | ( 799 | { 800 | "10": "50", 801 | "20": "0", 802 | "30": "100", 803 | }, 804 | [ 805 | (0, 0), 806 | (26, 128), 807 | (51, 0), 808 | (76, 255), 809 | (255, 255), 810 | ], 811 | ), 812 | # Empty configuration 813 | ( 814 | {}, 815 | [ 816 | (0, 0), 817 | (255, 255), 818 | ], 819 | ), 820 | # 100% values 821 | ( 822 | { 823 | "1": "100", 824 | "100": "50", 825 | }, 826 | [ 827 | (0, 0), 828 | (3, 255), 829 | (255, 128), 830 | ], 831 | ), 832 | ], 833 | ) 834 | def test_prepare_brightness_config(config, expected_result): 835 | """Test the prepare_brightness_config function.""" 836 | assert prepare_brightness_config(config) == expected_result 837 | 838 | 839 | @pytest.mark.parametrize( 840 | "lightener_level, expected_entity_level", 841 | [ 842 | (0, 0), 843 | (10, 30), 844 | (40, 0), 845 | (80, 90), 846 | (255, 255), 847 | (5, 15), 848 | (25, 15), 849 | (60, 45), 850 | ], 851 | ) 852 | def test_create_brightness_map(lightener_level, expected_entity_level): 853 | """Test the create_brightness_map function.""" 854 | 855 | config = [ 856 | (0, 0), 857 | (10, 30), 858 | (40, 0), 859 | (80, 90), 860 | (255, 255), 861 | ] 862 | brigtness_map = create_brightness_map(config) 863 | 864 | assert brigtness_map[lightener_level] == expected_entity_level 865 | 866 | # Check if the length is correct 867 | assert len(brigtness_map) == 256 868 | 869 | 870 | @pytest.mark.parametrize( 871 | "entity_level, expected_lightener_level_list", 872 | [ 873 | (0, [0, 40]), 874 | (15, [5, 25, 47]), 875 | (30, [10, 53]), 876 | (90, [80]), 877 | (255, [255]), 878 | ], 879 | ) 880 | def test_create_reverse_brightness_map(entity_level, expected_lightener_level_list): 881 | """Test the create_reverse_brightness_map function.""" 882 | 883 | config = [ 884 | (0, 0), 885 | (10, 30), 886 | (40, 0), 887 | (80, 90), 888 | (255, 255), 889 | ] 890 | 891 | levels = create_brightness_map(config) 892 | reverse_brightness_map = create_reverse_brightness_map(config, levels) 893 | 894 | assert reverse_brightness_map[entity_level] == expected_lightener_level_list 895 | 896 | # Check if the length is correct 897 | assert len(reverse_brightness_map) == 256 898 | 899 | 900 | def test_create_reverse_brightness_map_on_off(): 901 | """Test the create_reverse_brightness_map function.""" 902 | 903 | config = [ 904 | (0, 0), 905 | (10, 30), 906 | (40, 0), 907 | (80, 90), 908 | (255, 255), 909 | ] 910 | 911 | levels = create_brightness_map(config) 912 | reverse_brightness_map = create_reverse_brightness_map(config, levels) 913 | reverse_brightness_map_on_off = create_reverse_brightness_map_on_off( 914 | reverse_brightness_map 915 | ) 916 | 917 | # Expected off is a list with 0 and 40 918 | expected_lightener_level_list_off = [0, 40] 919 | 920 | # Expected on is a list that goes from 1 to 255, except 40 921 | expected_lightener_level_list_on = list(range(1, 40)) + list(range(41, 256)) 922 | 923 | assert reverse_brightness_map_on_off[0] == expected_lightener_level_list_off 924 | 925 | assert reverse_brightness_map_on_off[1] == expected_lightener_level_list_on 926 | assert reverse_brightness_map_on_off[10] == expected_lightener_level_list_on 927 | assert reverse_brightness_map_on_off[40] == expected_lightener_level_list_on 928 | assert reverse_brightness_map_on_off[45] == expected_lightener_level_list_on 929 | assert reverse_brightness_map_on_off[254] == expected_lightener_level_list_on 930 | assert reverse_brightness_map_on_off[255] == expected_lightener_level_list_on 931 | 932 | # Check if the length is correct 933 | assert len(reverse_brightness_map) == 256 934 | 935 | 936 | @pytest.mark.parametrize( 937 | "source_range, value, target_range, expected_result", 938 | [ 939 | # Positive order 940 | ((1, 255), 1, (1, 100), 1), 941 | ((1, 255), 255, (1, 100), 100), 942 | ((1, 255), 128, (1, 100), 50), 943 | # Low target range 944 | ((1, 255), 2, (1, 10), 1), 945 | ((1, 255), 15, (1, 10), 1), 946 | ((1, 255), 16, (1, 10), 2), 947 | ((1, 255), 25, (1, 10), 2), 948 | # Negative target order 949 | ((1, 255), 1, (255, 1), 255), 950 | ((1, 255), 255, (255, 1), 1), 951 | ((1, 255), 128, (255, 1), 128), 952 | # Negative source order 953 | ((255, 1), 1, (1, 100), 100), 954 | ((255, 1), 255, (1, 100), 1), 955 | ((255, 1), 26, (1, 100), 90), 956 | ], 957 | ) 958 | def test_scale_ranged_value_to_int_range( 959 | source_range, value, target_range, expected_result 960 | ): 961 | """Test the scale_ranged_value_to_int_range function.""" 962 | 963 | assert ( 964 | scale_ranged_value_to_int_range(source_range, target_range, value) 965 | == expected_result 966 | ) 967 | 968 | 969 | # Issues 970 | 971 | 972 | async def test_lightener_issue_41(hass: HomeAssistant, create_lightener): 973 | """Test the state changes of the LightenerLight class when turned on.""" 974 | 975 | lightener: LightenerLight = await create_lightener( 976 | config={ 977 | "friendly_name": "Test", 978 | "entities": { 979 | "light.test1": {}, 980 | "light.test2": {50: 0}, 981 | }, 982 | } 983 | ) 984 | 985 | await lightener.async_turn_on(brightness=30) 986 | await hass.async_block_till_done() 987 | assert lightener.brightness == 30 988 | 989 | await lightener.async_turn_off() 990 | await hass.async_block_till_done() 991 | assert lightener.brightness is None 992 | assert hass.states.get("light.test1").state == "off" 993 | assert hass.states.get("light.test2").state == "off" 994 | 995 | await lightener.async_turn_on() 996 | await hass.async_block_till_done() 997 | assert lightener.brightness == 30 998 | 999 | assert hass.states.get("light.test1").state == "on" 1000 | assert hass.states.get("light.test1").attributes["brightness"] == 30 1001 | assert hass.states.get("light.test2").state == "off" 1002 | 1003 | 1004 | async def test_lightener_issue_97(hass: HomeAssistant, create_lightener): 1005 | """Test the state changes of the LightenerLight class when turned on.""" 1006 | 1007 | lightener: LightenerLight = await create_lightener( 1008 | config={ 1009 | "friendly_name": "Test", 1010 | "entities": { 1011 | "light.test1": {50: 100}, 1012 | "light.test_onoff": {50: 0}, 1013 | }, 1014 | } 1015 | ) 1016 | 1017 | await lightener.async_turn_on(brightness=129) # 51% of 255 1018 | await hass.async_block_till_done() 1019 | assert lightener.brightness == 129 1020 | assert hass.states.get("light.test").attributes["brightness"] == 129 1021 | 1022 | assert hass.states.get("light.test1").state == "on" 1023 | assert hass.states.get("light.test_onoff").state == "on" 1024 | 1025 | await lightener.async_turn_on(brightness=200) 1026 | await hass.async_block_till_done() 1027 | assert lightener.brightness == 200 1028 | assert hass.states.get("light.test").attributes["brightness"] == 200 1029 | 1030 | assert hass.states.get("light.test1").state == "on" 1031 | assert hass.states.get("light.test_onoff").state == "on" 1032 | 1033 | assert hass.states.get("light.test1").attributes["brightness"] == 255 1034 | --------------------------------------------------------------------------------